20 Commits

Author SHA1 Message Date
f71ba55e8b Merge pull request 'feat: redesign player with Dynamic Island, compact timer, and fix Live Activity timer drift' (#2) from revamp-timer-video-layout into main
Some checks failed
CI / Detect Changes (push) Successful in 3s
CI / Admin Web CI (push) Failing after 34s
CI / YouTube Worker (push) Failing after 5s
CI / Deploy (push) Failing after 2s
Reviewed-on: #2
2026-05-23 12:24:34 +02:00
Millian Lamiaux
38576fd528 ci: replace dead Expo CI with linux-only monorepo pipeline
Some checks failed
CI / Admin Web CI (pull_request) Failing after 34s
CI / YouTube Worker (pull_request) Failing after 5s
CI / Deploy (pull_request) Has been skipped
CI / Detect Changes (pull_request) Successful in 6s
Remove: root Expo typecheck/lint/test/build-check (project deleted Apr 19)
Remove: swift-build-test + app-store.yml (no macOS runner on Gitea)
Add: path-filtered conditional jobs with dorny/paths-filter@v3
Add: youtube-worker deploy to deploy-functions job
Fix: deploy-functions always() to handle skipped upstream jobs
Fix: SPM cache key includes Package.resolved (before removal)
Fix: remove continue-on-error from vitest/playwright steps
2026-05-23 12:09:28 +02:00
Millian Lamiaux
df9fd48964 chore: update tabatago-swift submodule (Live Activity fix)
Some checks failed
CI / TypeScript (pull_request) Failing after 4s
CI / ESLint (pull_request) Failing after 4s
CI / Tests (pull_request) Failing after 6s
CI / Build Check (pull_request) Has been skipped
CI / Admin Web Tests (pull_request) Successful in 2m7s
CI / Deploy Edge Functions (pull_request) Has been skipped
Includes fix for stale Live Activity persisting after workout
cancel/background. See submodule commit e42c121 for details.
2026-05-23 00:41:41 +02:00
Millian Lamiaux
e42c1217db fix: Live Activity persists after workout cancel/background
Root cause: observeActivityState() prematurely set workoutActivity=nil
when the activity went .stale (e.g. app backgrounded >2 minutes). This
prevented endActivity() from calling .end() on the stale activity,
leaving it visible on the Lock Screen and Dynamic Island indefinitely.

Fixes (all in PlayerViewModel.swift):

1. observeActivityState(): Split the monolithic stale/ended/dismissed
   handler. .stale now only stops the sync timer but keeps the
   workoutActivity reference so endActivity() can still call .end()
   to properly dismiss the stale Live Activity.

2. syncActivity() nil guard: Changed from != .active to explicit
   == .ended || == .dismissed so stale activities are not prematurely
   discarded when tick() re-enters syncActivity().

3. endActivity(): Added stopActivitySyncTimer() defensive call at top
   to prevent orphaned timer from racing in and recreating the activity
   during .end(). Also relaxed the guard from == .active to
   != .ended && != .dismissed so stale activities can be ended.

4. abandonWorkout(): Explicitly set isRunning=false + isPaused=false
   before cleanup to prevent accidental Live Activity recreation.
2026-05-23 00:40:41 +02:00
Millian Lamiaux
cd6fea9b53 ci: add App Store submission pipeline via GitHub Actions
Some checks failed
CI / TypeScript (pull_request) Failing after 4s
CI / ESLint (pull_request) Failing after 4s
CI / Tests (pull_request) Failing after 12s
CI / Build Check (pull_request) Has been skipped
CI / Admin Web Tests (pull_request) Successful in 2m7s
CI / Deploy Edge Functions (pull_request) Has been skipped
2026-05-21 10:47:48 +02:00
Millian Lamiaux
d31b769ab8 chore: update docs and remove stale skill files 2026-05-21 10:21:57 +02:00
Millian Lamiaux
c152c22ffb feat: redesign Dynamic Island with phase-driven UI and animations 2026-05-21 10:21:22 +02:00
Millian Lamiaux
67e2bdc8c3 Redesign workout live activity with circular timer ring, phase icons, and smoother updates
Some checks failed
CI / TypeScript (pull_request) Failing after 19s
CI / ESLint (pull_request) Failing after 4s
CI / Tests (pull_request) Failing after 7s
CI / Build Check (pull_request) Has been skipped
CI / Admin Web Tests (pull_request) Successful in 2m9s
CI / Deploy Edge Functions (pull_request) Has been skipped
- Add CountdownRing with real-time arc progress on lock screen
- Replace generic dots with phase-specific SF Symbols (flame, snowflake, etc.)
- Remove horizontal progress bar in favor of round counter text
- Increase Dynamic Island expanded font sizes for better visibility
- Increase live activity sync frequency from 5s to 1s for smoother arc updates
- Add pause/resume button via TogglePauseIntent AppIntent
- Remove AlertConfiguration to silence notification sounds on updates
2026-05-17 00:43:01 +02:00
Millian Lamiaux
dc3ff15e81 feat: production-grade Live Activity with type-safe phases, decomposed views, previews, and alert transitions
- Replace raw string phase model with WorkoutPhase enum (Codable, Sendable, CaseIterable)
  with built-in .capitalized display name and SwiftUI .color per phase
- Decompose WorkoutLiveActivity into reusable view structs: PhasePill, CountdownText,
  WorkoutProgressBar, MusicInfoRow, HeartRateBadge, PhaseIndicatorDot, WorkoutLockScreenView,
  WorkoutSmallView — following CraftingSwift iOS 26 architecture patterns
- Add AlertConfiguration on work/rest/complete phase transitions so Dynamic Island
  expands and lights up at key moments
- Add 13 #Preview blocks across both widgets covering all presentation types:
  lock screen, expanded, compact, minimal — for instant Xcode Canvas feedback
- Add stale state handling (context.isStale shows 'Last updated' indicator)
- MusicLiveActivity: 5 new #Preview blocks for playing/paused/expanded/compact/minimal
2026-05-16 15:28:45 +02:00
Millian Lamiaux
95f34e6471 feat: Dynamic Island pause state, Apple-aligned spacing, and UI polish
- Add isPaused to WorkoutActivityAttributes.ContentState
- Show PAUSED badge, freeze timer to static text, dim content when paused
- Prevent stale spinner on pause by extending staleDate to 1 hour
- Add 6s timer warning color, progress bar, compact heavy timer
- Pulsing compact indicator during WORK phase
- Lock Screen margins aligned to Apple's 14pt HIG spec
2026-05-15 23:52:01 +02:00
Millian Lamiaux
057fbb3c9a fix: add 6s timeout to MusicService Supabase fetch for offline fallback
When airplane mode is active, the Supabase client hung indefinitely
waiting for a network response, blocking the mock track fallback.
Now races the query against a 6-second Task.sleep so mock tracks
load immediately after timeout.
2026-05-15 23:51:51 +02:00
Millian Lamiaux
918e663dbf chore: update Xcode project for widget target and watch team
- Add widget target PBXBuildFile, PBXFileReference, PBXGroup, PBXNativeTarget entries
- Reorder PBXCopyFilesBuildPhase and XCBuildConfiguration sections
- Add DEVELOPMENT_TEAM to watch complication and watch app configs
2026-05-15 22:41:35 +02:00
Millian Lamiaux
fe005ee7f3 feat: Live Activity accessibility and supplemental families (small/medium)
- Add @Environment activityFamily, isActivityFullscreen, isLuminanceReduced
- Split into lockScreenView() and smallLockScreenView() variants
- Add supplementalActivityFamilies([.small, .medium]) support
- Add keylineTint and contentMargins to Dynamic Island
- Add accessibility labels throughout (VoiceOver support)
- Hide music bar animation when isLuminanceReduced
2026-05-15 22:41:20 +02:00
Millian Lamiaux
71de3c0aa7 fix: Live Activity concurrency and state observation
- Add Sendable conformance to MusicActivityAttributes.ContentState
- Remove @preconcurrency on ActivityKit import
- Use nonisolated(unsafe) guards for Activity refs in task closures
- Add observeActivityState() to handle stale/ended/dismissed activity states
- Set staleDate (120s) instead of nil for push notification support
2026-05-15 22:41:04 +02:00
Millian Lamiaux
03f660958f add agent skills and opencode config 2026-05-10 20:09:13 +01:00
Millian Lamiaux
349a96379e add mock programs fallback for offline development 2026-05-10 19:20:30 +01:00
Millian Lamiaux
d43142641f refactor: clean up Dynamic Island expanded layout with phase badge, symmetric edges, and music+heart rate in bottom region 2026-05-03 20:59:03 +02:00
Millian Lamiaux
c715c797f9 fix: move Live Activity ownership to ViewModel, fix timer-at-0 and background freeze
**Architecture (PlayerViewModel):**
- Move ActivityKit lifecycle from SwiftUI View to ViewModel (MVVM correction)
- call syncActivity() at END of enterPhase() — after all state is set,
  eliminating the race where phase was Published before timeRemaining
- Always recalculate phaseEndDate = Date() + timeRemaining (no stale cache)
- Dedicated Timer in ViewModel for periodic heart-rate/track sync (5s)
- Start/stop activity sync timer on play/pause/resume/abandon/finish
- stale activity reference discard + recreate-on-failure fallback
- Modern iOS 16.2+ API: ActivityContent, non-throwing update()

**PlayerView:**
- Remove all ActivityKit code (import, @State workoutActivity,
  phaseEndDate, dynamicIslandAvailable, 4 methods, .onReceive timer)
- Delegate to ViewModel: onChange(musicVM.currentTrack) sets vm.trackTitle/Artist
  and calls vm.syncActivity(); onDisappear calls await vm.endActivity()
- Music/audio onChange handlers no longer contain activity logic

**Info.plist:**
- Add UIBackgroundModes → audio so music continues and app stays alive
  in background, allowing Timer-based activity updates
- Widget Info.plist: add NSSupportsLiveActivitiesFrequentUpdates

**WorkoutActivityAttributes.ContentState:**
- Add Sendable conformance for Swift 6 strict concurrency

Fixes: timer stuck at 0 on first work phase, exercise name missing,
       music stopping in background, Dynamic Island freezing in background,
       widget drift due to cached phaseEndDate
2026-05-03 15:40:36 +02:00
Millian Lamiaux
b0d364eca2 feat: redesign player with Dynamic Island, compact timer, and fix Live Activity timer drift
Some checks failed
CI / TypeScript (pull_request) Failing after 5s
CI / ESLint (pull_request) Failing after 3s
CI / Tests (pull_request) Failing after 5s
CI / Build Check (pull_request) Has been skipped
CI / Admin Web Tests (pull_request) Successful in 2m5s
CI / Deploy Edge Functions (pull_request) Has been skipped
## What changed

### Player Redesign (video-first layout)
- New compact timer ring (110pt) with phase label, replaces 240pt ring
- Auto-hide top bar with block progress dots (3s auto-dismiss)
- Expandable now-playing music pill with skip control
- Bottom control bar with heart rate, play/pause, and skip
- Exercise caption with 'Next' preview during rest phases
- Compact round counter (capsule dots)

### Dynamic Island & Live Activities
- WorkoutLiveActivity widget: expanded, compact, and minimal views
- Phase-colored timers with Text(timerInterval:) countdown
- Shows exercise name, round progress, heart rate, music track
- MusicLiveActivity: standalone music now-playing widget
- LiveActivityMusicBars animated component
- Deep link from Dynamic Island back to app

### Timer Drift Fix (critical)
- Store a stable phaseEndDate once per phase instead of
  recalculating Date() + timeRemaining on every update
- Prevents dynamic island countdown from rubber-banding
  due to 5-second periodic update recalculation drift
- Reset phaseEndDate on phase change and resume from pause
- Guard Live Activity updates behind vm.isRunning to prevent
  premature creation when music track loads before workout start
- Fixes timer showing 0 in Dynamic Island when expanding
  from home screen

### New PlayerViewModel timer engine
- Full phase support: prep, warmup, work, rest,
  interBlockRest, cooldown, complete
- 1-second countdown with audio cues at 3-2-1
- Phase transitions with spring animation and haptics
- HealthKit live session integration
- Workout session recording with completion

### Music Service
- New MusicPlayerViewModel with vibe-based playlist loading
- Track info exposed for Dynamic Island display
- Skip track support from Dynamic Island notification action
- Automatic play/pause based on phase and running state

### Additional
- ZoneHighlightIcon component for HomeTab zone cards
- Updated watchOS localizations with complication strings
- Info.plist updated for widget extension
2026-04-25 23:51:46 +02:00
7f5ea9c6e9 Merge pull request 'fix: add HealthKit entitlement and regenerate Xcode project to resolve NSInvalidArgumentException' (#1) from fix/healthkit-info-plist into main
Some checks failed
CI / TypeScript (push) Failing after 3s
CI / ESLint (push) Failing after 4s
CI / Tests (push) Failing after 5s
CI / Build Check (push) Has been skipped
CI / Admin Web Tests (push) Successful in 2m5s
CI / Deploy Edge Functions (push) Has been skipped
Reviewed-on: #1
2026-04-23 22:39:25 +02:00
131 changed files with 19395 additions and 2630 deletions

View File

@@ -0,0 +1,82 @@
---
name: core-data-expert
description: 'Expert Core Data guidance (iOS/macOS): stack setup, fetch requests & NSFetchedResultsController, saving/merge conflicts, threading & Swift Concurrency, batch operations & persistent history, migrations, performance, and NSPersistentCloudKitContainer/CloudKit sync.'
---
# Core Data Expert
Fast, production-oriented guidance for building **correct**, **performant** Core Data stacks and fixing common crashes.
## Agent behavior contract (follow these rules)
1. Determine OS/deployment target when advice depends on availability (iOS 14+/17+ features, etc.).
2. Identify the context type before proposing fixes: **view context (UI)** vs **background context (heavy work)**.
3. Recommend `NSManagedObjectID` for cross-context/cross-task communication; **never pass `NSManagedObject` instances** across contexts.
4. Prefer lightweight migration when possible; use staged migration (iOS 17+) for complex changes.
5. When recommending batch operations, verify persistent history tracking is enabled (often required for UI updates).
6. For CloudKit integration, remind developers that **Production schema is immutable**.
7. Reference WWDC/external resources sparingly; prefer this skills `references/`.
## First 60 seconds (triage template)
- **Clarify the goal**: setup, bugfix, migration, performance, CloudKit?
- **Collect minimal facts**:
- platform + deployment target
- store type (SQLite / in-memory) and whether CloudKit is enabled
- context involved (view vs background) and whether Swift Concurrency is in use
- exact error message + stack trace/logs
- **Branch immediately**:
- threading/crash → focus on context confinement + `NSManagedObjectID` handoff
- migration error → identify model versions + migration strategy
- batch ops not updating UI → persistent history tracking + merge pipeline
## Routing map (pick the right reference fast)
- **Stack setup / merge policies / contexts** → `references/stack-setup.md`
- **Saving patterns** → `references/saving.md`
- **Fetch requests / list updates / aggregates** → `references/fetch-requests.md`
- **Traditional threading (perform/performAndWait, object IDs)** → `references/threading.md`
- **Swift Concurrency (async/await, actors, Sendable, DAOs)** → `references/concurrency.md`
- **Batch insert/delete/update** → `references/batch-operations.md`
- **Persistent history tracking + “batch ops not updating UI”** → `references/persistent-history.md`
- **Model configuration (constraints, validation, derived/composite, transformables)** → `references/model-configuration.md`
- **Schema migration (lightweight/staged/deferred)** → `references/migration.md`
- **CloudKit integration & debugging** → `references/cloudkit-integration.md`
- **Performance profiling & memory** → `references/performance.md`
- **Testing patterns** → `references/testing.md`
- **Terminology** → `references/glossary.md`
## Common errors → next best move
- **“Failed to find a unique match for an NSEntityDescription”** → `references/testing.md` (shared `NSManagedObjectModel`)
- **`NSPersistentStoreIncompatibleVersionHashError`** → `references/migration.md` (versioning + migration)
- **Cross-context/threading exceptions** (e.g. delete/update from wrong context) → `references/threading.md` and/or `references/concurrency.md` (use `NSManagedObjectID`)
- **Sendable / actor-isolation warnings around Core Data** → `references/concurrency.md` (dont “paper over” with `@unchecked Sendable`)
- **`NSMergeConflict` / constraint violations** → `references/model-configuration.md` + `references/stack-setup.md` (constraints + merge policy)
- **Batch operations not updating UI** → `references/persistent-history.md` + `references/batch-operations.md`
- **CloudKit schema/sync issues** → `references/cloudkit-integration.md`
- **Memory grows during fetch** → `references/performance.md` + `references/fetch-requests.md`
## Verification checklist (when changing Core Data code)
- Confirm the context matches the work (UI vs background).
- Ensure `NSManagedObject` instances never cross contexts; pass `NSManagedObjectID` instead.
- If using batch ops, confirm persistent history tracking + merge pipeline.
- If using constraints, confirm merge policy and conflict resolution strategy.
- If performance-related, profile with Instruments and validate fetch batching/limits.
## Reference files
- `references/_index.md` (navigation)
- `references/stack-setup.md`
- `references/saving.md`
- `references/fetch-requests.md`
- `references/threading.md`
- `references/concurrency.md`
- `references/batch-operations.md`
- `references/persistent-history.md`
- `references/model-configuration.md`
- `references/migration.md`
- `references/cloudkit-integration.md`
- `references/performance.md`
- `references/testing.md`
- `references/glossary.md`

View File

@@ -0,0 +1,82 @@
# Reference Index
Quick navigation for Core Data topics.
## Fundamentals
- `stack-setup.md`: NSPersistentContainer setup, merge policies, context configuration
- `saving.md`: Conditional saving, hasPersistentChanges, save timing strategies
- `glossary.md`: Term definitions for quick lookup
- `project-audit.md`: Checklist for discovering a projects Core Data setup and constraints
## Data Access
- `fetch-requests.md`: Query optimization, NSFetchedResultsController, aggregates
- `threading.md`: NSManagedObjectID, perform vs performAndWait, concurrency
- `concurrency.md`: Swift Concurrency integration, async/await, actors, Sendable
- `batch-operations.md`: NSBatchInsertRequest, NSBatchDeleteRequest, NSBatchUpdateRequest
## Model & Schema
- `model-configuration.md`: Constraints, derived attributes, transformables, validation, lifecycle
- `migration.md`: Lightweight, staged, and deferred migration strategies
## Advanced Topics
- `persistent-history.md`: History tracking setup, Observer/Fetcher/Merger/Cleaner pattern
- `cloudkit-integration.md`: NSPersistentCloudKitContainer, schema design, monitoring
- `performance.md`: Profiling with Instruments, memory management, optimization
- `testing.md`: In-memory stores, shared models, data generators
## Quick Links by Problem
### "I need to..."
- **Set up Core Data** → `stack-setup.md`
- **Save data efficiently** → `saving.md`
- **Fetch and display data** → `fetch-requests.md`
- **Work with background threads** → `threading.md`
- **Use async/await with Core Data** → `concurrency.md`
- **Import large datasets** → `batch-operations.md`
- **Configure my model** → `model-configuration.md`
- **Migrate my schema** → `migration.md`
- **Sync with CloudKit** → `cloudkit-integration.md`
- **Optimize performance** → `performance.md`
- **Write tests** → `testing.md`
### "I'm getting an error about..."
- **"NSPersistentStoreIncompatibleVersionHashError"** → `migration.md`
- **"Cannot delete objects in other contexts"** → `threading.md`
- **"NSMergeConflict"** → `stack-setup.md` (merge policies), `model-configuration.md` (constraints)
- **"Failed to find unique match for NSEntityDescription"** → `testing.md` (shared model)
- **Batch operations not updating UI** → `persistent-history.md`
- **CloudKit sync issues** → `cloudkit-integration.md`
- **Memory growing unbounded** → `performance.md`, `fetch-requests.md`
- **Validation errors** → `model-configuration.md`
### "I want to..."
- **Optimize queries** → `fetch-requests.md`, `performance.md`
- **Handle relationships** → `model-configuration.md`, `fetch-requests.md`
- **Validate data** → `model-configuration.md`
- **Track changes across contexts** → `persistent-history.md`
- **Debug performance issues** → `performance.md`
- **Test my Core Data code** → `testing.md`
## File Statistics
- `project-audit.md`: Project discovery checklist (deployment target, stack, history tracking, concurrency risks)
- `stack-setup.md`: NSPersistentContainer, merge policies, context configuration
- `saving.md`: hasPersistentChanges, conditional saving, error handling
- `fetch-requests.md`: Optimization, NSFetchedResultsController, aggregates, diffable data sources
- `threading.md`: NSManagedObjectID, perform/performAndWait, traditional threading
- `concurrency.md`: Swift Concurrency, async/await, actors, Sendable, @MainActor, DAOs
- `batch-operations.md`: NSBatchInsertRequest, NSBatchDeleteRequest, NSBatchUpdateRequest
- `model-configuration.md`: Constraints, derived attributes, transformables, validation, lifecycle
- `migration.md`: Lightweight, staged (iOS 17+), deferred (iOS 14+), composite attributes
- `persistent-history.md`: Observer, Fetcher, Merger, Cleaner, batch operation integration
- `cloudkit-integration.md`: NSPersistentCloudKitContainer, schema design, monitoring, debugging
- `performance.md`: Instruments profiling, memory management, optimization strategies
- `testing.md`: In-memory stores, shared models, data generators, XCTest patterns
- `glossary.md`: Core Data terminology and quick definitions

View File

@@ -0,0 +1,543 @@
# Batch Operations
Batch operations provide significant performance improvements for large-scale data modifications. They operate directly at the SQL level, bypassing the object graph.
## Overview
Core Data provides three batch operation types:
- **NSBatchInsertRequest** - Bulk inserts (iOS 14+)
- **NSBatchDeleteRequest** - Bulk deletes
- **NSBatchUpdateRequest** - Bulk updates
**Key Characteristics:**
- Operate at SQL level (very fast)
- Don't load objects into memory
- Don't trigger validation
- Don't send change notifications (requires persistent history tracking)
- Can't set relationships during batch insert
## NSBatchInsertRequest (iOS 14+)
### Basic Usage
```swift
let context = container.newBackgroundContext()
context.perform {
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { (object: NSManagedObject) -> Bool in
guard let article = object as? Article else { return true }
article.name = "Sample Article"
article.content = "Content here"
article.creationDate = Date()
return false // Continue inserting
}
do {
try context.execute(batchInsert)
} catch {
print("Batch insert failed: \(error)")
}
}
```
### Inserting Multiple Objects
```swift
func batchInsertArticles(_ data: [ArticleData]) {
let context = container.newBackgroundContext()
context.perform {
var index = 0
let batchInsert = NSBatchInsertRequest(
entity: Article.entity()
) { (object: NSManagedObject) -> Bool in
guard index < data.count else { return true } // Stop
guard let article = object as? Article else { return true }
let articleData = data[index]
article.name = articleData.name
article.content = articleData.content
article.creationDate = Date()
index += 1
return false // Continue
}
do {
try context.execute(batchInsert)
} catch {
print("Batch insert failed: \(error)")
}
}
}
```
### Using Dictionary Representation (Alternative)
```swift
let context = container.newBackgroundContext()
context.perform {
let objects: [[String: Any]] = [
["name": "Article 1", "content": "Content 1", "creationDate": Date()],
["name": "Article 2", "content": "Content 2", "creationDate": Date()],
["name": "Article 3", "content": "Content 3", "creationDate": Date()]
]
let batchInsert = NSBatchInsertRequest(
entity: Article.entity(),
objects: objects
)
do {
try context.execute(batchInsert)
} catch {
print("Batch insert failed: \(error)")
}
}
```
### Limitations
**Cannot set relationships:**
```swift
// This won't work
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { object in
guard let article = object as? Article else { return true }
article.category = someCategory // Can't set relationships!
return false
}
```
**Workaround:** Set relationships after batch insert:
```swift
// 1. Batch insert articles
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { object in
guard let article = object as? Article else { return true }
article.name = "Article"
return false
}
try context.execute(batchInsert)
// 2. Fetch and set relationships
let fetchRequest = Article.fetchRequest()
let articles = try context.fetch(fetchRequest)
for article in articles {
article.category = defaultCategory
}
try context.save()
```
## NSBatchDeleteRequest
### Basic Usage
```swift
let context = container.newBackgroundContext()
context.perform {
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = Article.fetchRequest()
let batchDelete = NSBatchDeleteRequest(fetchRequest: fetchRequest)
do {
try context.execute(batchDelete)
} catch {
print("Batch delete failed: \(error)")
}
}
```
### With Predicate
```swift
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = Article.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "views < %d", 10)
let batchDelete = NSBatchDeleteRequest(fetchRequest: fetchRequest)
context.perform {
do {
try context.execute(batchDelete)
} catch {
print("Batch delete failed: \(error)")
}
}
```
### Getting Deleted Object IDs
```swift
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = Article.fetchRequest()
let batchDelete = NSBatchDeleteRequest(fetchRequest: fetchRequest)
batchDelete.resultType = .resultTypeObjectIDs
context.perform {
do {
let result = try context.execute(batchDelete) as? NSBatchDeleteResult
if let objectIDs = result?.result as? [NSManagedObjectID] {
print("Deleted \(objectIDs.count) objects")
}
} catch {
print("Batch delete failed: \(error)")
}
}
```
## NSBatchUpdateRequest
### Basic Usage
```swift
let context = container.newBackgroundContext()
context.perform {
let batchUpdate = NSBatchUpdateRequest(entityName: "Article")
batchUpdate.predicate = NSPredicate(format: "isRead == NO")
batchUpdate.propertiesToUpdate = ["isRead": true]
do {
try context.execute(batchUpdate)
} catch {
print("Batch update failed: \(error)")
}
}
```
### Updating Multiple Properties
```swift
let batchUpdate = NSBatchUpdateRequest(entityName: "Article")
batchUpdate.predicate = NSPredicate(format: "views < %d", 100)
batchUpdate.propertiesToUpdate = [
"views": 100,
"lastModified": Date(),
"isPopular": true
]
context.perform {
try? context.execute(batchUpdate)
}
```
### Using Expressions
```swift
// Increment views by 1
let batchUpdate = NSBatchUpdateRequest(entityName: "Article")
batchUpdate.propertiesToUpdate = [
"views": NSExpression(format: "views + 1")
]
context.perform {
try? context.execute(batchUpdate)
}
```
### Getting Updated Object IDs
```swift
let batchUpdate = NSBatchUpdateRequest(entityName: "Article")
batchUpdate.propertiesToUpdate = ["isRead": true]
batchUpdate.resultType = .updatedObjectIDsResultType
context.perform {
do {
let result = try context.execute(batchUpdate) as? NSBatchUpdateResult
if let objectIDs = result?.result as? [NSManagedObjectID] {
print("Updated \(objectIDs.count) objects")
}
} catch {
print("Batch update failed: \(error)")
}
}
```
## Persistent History Tracking Integration
**Critical:** Batch operations don't send change notifications. You **must** enable persistent history tracking for UI updates.
### Enable Persistent History Tracking
```swift
guard let description = container.persistentStoreDescriptions.first else { return }
description.setOption(true as NSNumber,
forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
```
### Observe Remote Changes
```swift
NotificationCenter.default.addObserver(
self,
selector: #selector(storeRemoteChange),
name: .NSPersistentStoreRemoteChange,
object: container.persistentStoreCoordinator
)
@objc func storeRemoteChange(_ notification: Notification) {
// Merge changes into view context
// See persistent-history.md for full implementation
}
```
## Performance Comparison
### Traditional Insert (Slow)
```swift
// Inserting 1000 objects: ~10 seconds
for i in 0..<1000 {
let article = Article(context: context)
article.name = "Article \(i)"
}
try context.save()
```
### Batch Insert (Fast)
```swift
// Inserting 1000 objects: ~0.5 seconds
var index = 0
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { object in
guard index < 1000 else { return true }
guard let article = object as? Article else { return true }
article.name = "Article \(index)"
index += 1
return false
}
try context.execute(batchInsert)
```
**Performance gain: ~20x faster**
## When to Use Batch Operations
### Use Batch Insert When:
- Importing large datasets (>100 objects)
- Initial data seeding
- Syncing data from server
- Performance is critical
### Use Batch Delete When:
- Deleting many objects at once
- Clearing old data
- Implementing data retention policies
- Performance is critical
### Use Batch Update When:
- Updating many objects with same values
- Bulk status changes
- Incrementing counters
- Performance is critical
### Don't Use Batch Operations When:
- Need to set relationships
- Need validation
- Need to trigger lifecycle events (willSave, etc.)
- Working with small datasets (<50 objects)
- Need immediate UI updates without persistent history tracking
## Complete Example: Import with Batch Insert
```swift
class DataImporter {
let container: NSPersistentContainer
init(container: NSPersistentContainer) {
self.container = container
}
func importArticles(_ data: [ArticleData]) {
let context = container.newBackgroundContext()
context.perform {
var index = 0
let batchInsert = NSBatchInsertRequest(
entity: Article.entity()
) { (object: NSManagedObject) -> Bool in
guard index < data.count else { return true }
guard let article = object as? Article else { return true }
let articleData = data[index]
article.name = articleData.name
article.content = articleData.content
article.views = 0
article.creationDate = Date()
index += 1
return false
}
do {
let result = try context.execute(batchInsert) as? NSBatchInsertResult
print("Inserted \(data.count) articles")
// If you need the object IDs
if let objectIDs = result?.result as? [NSManagedObjectID] {
print("Object IDs: \(objectIDs)")
}
} catch {
print("Batch insert failed: \(error)")
}
}
}
}
```
## Complete Example: Cleanup with Batch Delete
```swift
class DataCleaner {
let container: NSPersistentContainer
init(container: NSPersistentContainer) {
self.container = container
}
func deleteOldArticles(olderThan days: Int) {
let context = container.newBackgroundContext()
context.perform {
let cutoffDate = Calendar.current.date(
byAdding: .day,
value: -days,
to: Date()
)!
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = Article.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "creationDate < %@",
cutoffDate as NSDate
)
let batchDelete = NSBatchDeleteRequest(fetchRequest: fetchRequest)
batchDelete.resultType = .resultTypeCount
do {
let result = try context.execute(batchDelete) as? NSBatchDeleteResult
if let count = result?.result as? Int {
print("Deleted \(count) old articles")
}
} catch {
print("Batch delete failed: \(error)")
}
}
}
}
```
## Common Pitfalls
### ❌ Not Enabling Persistent History Tracking
```swift
// Batch insert happens
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { ... }
try context.execute(batchInsert)
// UI doesn't update! No notifications sent
```
### ❌ Trying to Set Relationships
```swift
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { object in
guard let article = object as? Article else { return true }
article.category = category // Won't work!
return false
}
```
### ❌ Expecting Validation
```swift
// No validation happens!
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { object in
guard let article = object as? Article else { return true }
article.name = "" // Empty name - no validation error
return false
}
```
### ❌ Using on View Context
```swift
// Don't use batch operations on view context
viewContext.perform {
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { ... }
try? viewContext.execute(batchInsert) // Blocks UI!
}
```
### ✅ Correct Approach
```swift
// 1. Enable persistent history tracking
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
// 2. Use background context
let context = container.newBackgroundContext()
// 3. Execute batch operation
context.perform {
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { object in
guard let article = object as? Article else { return true }
article.name = "Valid Name"
return false
}
try? context.execute(batchInsert)
}
// 4. UI updates via persistent history tracking
```
## Testing Batch Operations
```swift
func testBatchInsert() throws {
let context = container.newBackgroundContext()
let expectation = XCTestExpectation(description: "Batch insert")
context.perform {
var count = 0
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { object in
guard count < 10 else { return true }
guard let article = object as? Article else { return true }
article.name = "Article \(count)"
count += 1
return false
}
do {
try context.execute(batchInsert)
expectation.fulfill()
} catch {
XCTFail("Batch insert failed: \(error)")
}
}
wait(for: [expectation], timeout: 5.0)
// Verify
let fetchRequest = Article.fetchRequest()
let articles = try context.fetch(fetchRequest)
XCTAssertEqual(articles.count, 10)
}
```
## Summary
1. **Use batch operations for large datasets** - 10-20x performance improvement
2. **Enable persistent history tracking** - Required for UI updates
3. **Use background contexts** - Don't block UI
4. **Can't set relationships in batch insert** - Set them separately if needed
5. **No validation or lifecycle events** - Batch operations bypass object graph
6. **Get result types** - Use resultType to get object IDs or counts
7. **Test thoroughly** - Verify data integrity after batch operations
8. **Consider trade-offs** - Speed vs validation/relationships/lifecycle events

View File

@@ -0,0 +1,259 @@
# CloudKit Integration
`NSPersistentCloudKitContainer` syncs Core Data with CloudKit, enabling seamless data synchronization across devices.
## Setup
### Basic Setup
```swift
import CoreData
import CloudKit
let container = NSPersistentCloudKitContainer(name: "Model")
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Failed to load store: \(error)")
}
}
```
### Configure CloudKit Container
In Xcode:
1. Add CloudKit capability
2. Select or create CloudKit container
3. Enable "Use CloudKit" in Core Data model
### Schema Design Limitations
CloudKit has restrictions Core Data doesn't:
**Not Supported:**
- Unique constraints on entities
- `Undefined` attribute type
- `ObjectID` attribute type
- Non-optional relationships (must be optional)
- Relationships without inverse
- Deny deletion rule
**Supported:**
- Adding new fields to record types
- Adding new record types
**Important:** Production schema is **immutable**. Plan carefully!
## Schema Initialization
### Development Environment
```swift
// First run initializes schema in Development
container.loadPersistentStores { description, error in
// Schema created automatically
}
```
### Promoting to Production
1. Test thoroughly in Development
2. Open CloudKit Dashboard
3. Deploy schema to Production
4. **Cannot modify after deployment!**
## Monitoring Sync
### Observe Events
```swift
NotificationCenter.default.addObserver(
self,
selector: #selector(storeDidChange),
name: NSPersistentCloudKitContainer.eventChangedNotification,
object: container
)
@objc func storeDidChange(_ notification: Notification) {
guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
as? NSPersistentCloudKitContainer.Event else {
return
}
switch event.type {
case .setup:
print("Setup: \(event.succeeded ? "succeeded" : "failed")")
case .import:
print("Import: \(event.succeeded ? "succeeded" : "failed")")
case .export:
print("Export: \(event.succeeded ? "succeeded" : "failed")")
@unknown default:
break
}
if let error = event.error {
print("Error: \(error)")
}
}
```
### Testing Sync
```swift
func testSync() {
let expectation = XCTestExpectation(description: "Export")
// Create expectation for export
let observer = NotificationCenter.default.addObserver(
forName: NSPersistentCloudKitContainer.eventChangedNotification,
object: container,
queue: nil
) { notification in
guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
as? NSPersistentCloudKitContainer.Event else {
return
}
if event.type == .export && event.endDate != nil {
expectation.fulfill()
}
}
// Make changes
let article = Article(context: container.viewContext)
article.name = "Test"
try? container.viewContext.save()
wait(for: [expectation], timeout: 60)
NotificationCenter.default.removeObserver(observer)
}
```
## Cross-Version Compatibility
### Strategy 1: Incremental Fields
Add new fields, keep old ones:
```swift
// V1: name
// V2: name, subtitle (new)
// Old versions see records but not subtitle
```
### Strategy 2: Version Attribute
```swift
// Add version attribute
article.schemaVersion = 2
// Filter in fetch requests
fetchRequest.predicate = NSPredicate(format: "schemaVersion <= %d", currentVersion)
```
### Strategy 3: New Container
```swift
let options = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.com.example.app.v2"
)
let description = NSPersistentStoreDescription(url: storeURL)
description.cloudKitContainerOptions = options
```
**Caution:** Large datasets take time to upload.
## Debugging
### System Logs
Monitor these processes:
- **Application** - Core Data activity
- **dasd** - Scheduling decisions
- **cloudd** - CloudKit operations
- **apsd** - Push notifications
### Using log stream
```bash
# Application logs
log stream --predicate 'process == "YourApp"'
# CloudKit logs
log stream --predicate 'process == "cloudd" AND message CONTAINS "your.container.id"'
# Push notifications
log stream --predicate 'process == "apsd"'
# Scheduling
log stream --predicate 'process == "dasd" AND message CONTAINS "YourApp"'
```
### CloudKit Logging Profile
1. Download from [Apple Developer Portal](https://developer.apple.com/bug-reporting/profiles-and-logs/)
2. Install on device
3. Reboot device
4. Reproduce issue
5. Collect sysdiagnose
### Collecting Diagnostics
**sysdiagnose:**
- iOS: Volume Up + Volume Down + Power (hold)
- macOS: Shift + Control + Option + Command + Period
## Common Issues
### Schema Mismatch
**Problem:** Local schema doesn't match CloudKit schema.
**Solution:**
1. Delete app
2. Reinstall
3. Let schema reinitialize
### Sync Not Working
**Checklist:**
- [ ] CloudKit capability enabled
- [ ] Signed in to iCloud
- [ ] Network connection available
- [ ] CloudKit container configured
- [ ] Schema initialized in Development
- [ ] Schema promoted to Production
### Large Initial Sync
**Problem:** First sync takes too long.
**Solutions:**
- Use background fetch
- Show progress indicator
- Implement data generators for testing
## Best Practices
1. **Test in Development first** - Schema is mutable
2. **Plan schema carefully** - Production is immutable
3. **Make relationships optional** - Required by CloudKit
4. **Add inverse relationships** - Required by CloudKit
5. **Version your data** - For cross-version compatibility
6. **Monitor sync events** - Detect and handle errors
7. **Test with multiple devices** - Verify sync behavior
8. **Handle conflicts** - Use appropriate merge policy
9. **Collect diagnostics** - For debugging sync issues
10. **Consider data size** - Large datasets take time to sync
## Summary
- Use `NSPersistentCloudKitContainer` for CloudKit sync
- Schema has limitations (optional relationships, no constraints)
- Production schema is immutable
- Monitor sync with event notifications
- Test thoroughly in Development before promoting
- Plan for cross-version compatibility
- Use system logs for debugging
- Collect sysdiagnose for complex issues

View File

@@ -0,0 +1,522 @@
# Core Data and Swift Concurrency
Thread-safe patterns for using Core Data with Swift Concurrency.
## Core Principles
### Thread safety still matters
Core Data's thread safety rules don't change with Swift Concurrency:
- Can't pass `NSManagedObject` between threads
- Must access objects on their context's thread
- `NSManagedObjectID` is thread-safe (can pass around)
### NSManagedObject cannot be Sendable
```swift
@objc(Article)
public class Article: NSManagedObject {
@NSManaged public var title: String // Mutable, can't be Sendable
}
```
**Don't use `@unchecked Sendable`** - hides warnings without fixing safety.
## Available Async APIs
### Context perform
```swift
extension NSManagedObjectContext {
func perform<T>(_ block: @escaping () throws -> T) async rethrows -> T
}
```
### What's missing
No async alternative for:
```swift
func loadPersistentStores(
completionHandler: @escaping (NSPersistentStoreDescription, Error?) -> Void
)
```
Must bridge manually (see below).
## Data Access Objects (DAO)
Thread-safe value types representing managed objects.
### Pattern
```swift
// Managed object (not Sendable)
@objc(Article)
public class Article: NSManagedObject {
@NSManaged public var title: String?
@NSManaged public var timestamp: Date?
}
// DAO (Sendable)
struct ArticleDAO: Sendable, Identifiable {
let id: NSManagedObjectID
let title: String
let timestamp: Date
init?(managedObject: Article) {
guard let title = managedObject.title,
let timestamp = managedObject.timestamp else {
return nil
}
self.id = managedObject.objectID
self.title = title
self.timestamp = timestamp
}
}
```
### Benefits
- **Sendable**: Safe to pass across isolation domains
- **Immutable**: No accidental mutations
- **Clear API**: Explicit data transfer
### Drawbacks
- **Requires rewrite**: All fetch/mutation logic
- **Boilerplate**: DAO for each entity
- **Complexity**: Additional layer of abstraction
## Working Without DAOs
Pass only `NSManagedObjectID` between contexts.
### Basic pattern
```swift
@MainActor
func fetchArticle(id: NSManagedObjectID) -> Article? {
viewContext.object(with: id) as? Article
}
func processInBackground(articleID: NSManagedObjectID) async throws {
let backgroundContext = container.newBackgroundContext()
try await backgroundContext.perform {
guard let article = backgroundContext.object(with: articleID) as? Article else {
return
}
// Process article
try backgroundContext.save()
}
}
```
### NSManagedObjectID is Sendable
```swift
// Safe to pass between tasks
let articleID = article.objectID
Task {
try? await processInBackground(articleID: articleID)
}
```
## Bridging Closures to Async
### Load persistent stores
```swift
extension NSPersistentContainer {
func loadPersistentStores() async throws {
try await withCheckedThrowingContinuation { continuation in
self.loadPersistentStores { description, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: ())
}
}
}
}
}
// Usage
try await container.loadPersistentStores()
```
## Simple CoreDataStore Pattern
Enforce isolation at API level:
```swift
final class CoreDataStore {
let persistentContainer: NSPersistentContainer
var viewContext: NSManagedObjectContext {
persistentContainer.viewContext
}
init(persistentContainer: NSPersistentContainer) {
self.persistentContainer = persistentContainer
}
// View context operations (main thread)
@MainActor
func read<T>(_ block: (NSManagedObjectContext) throws -> T) rethrows -> T {
try block(viewContext)
}
// Background operations
func performInBackground<T>(
_ block: @Sendable @escaping (NSManagedObjectContext) throws -> T
) async rethrows -> T {
let context = persistentContainer.newBackgroundContext()
return try await context.perform {
try block(context)
}
}
}
```
### Usage
```swift
let store = CoreDataStore(persistentContainer: container)
// Main thread operations
@MainActor
func loadArticles() throws -> [Article] {
try store.read { context in
let request = Article.fetchRequest()
return try context.fetch(request)
}
}
// Background operations
func deleteAll() async throws {
try await store.performInBackground { context in
let request = Article.fetchRequest()
let articles = try context.fetch(request)
articles.forEach { context.delete($0) }
try context.save()
}
}
```
### Why this pattern works
- **@MainActor**: Enforces view context on main thread
- **Dedicated entry points**: Read/write APIs prevent accidental cross-context use
- **Simple**: No custom executors needed
## Custom Actor Executor (Advanced)
**Note**: Usually not needed. Consider simple pattern first.
### Implementation
```swift
final class NSManagedObjectContextExecutor: @unchecked Sendable, SerialExecutor {
private let context: NSManagedObjectContext
init(context: NSManagedObjectContext) {
self.context = context
}
func enqueue(_ job: consuming ExecutorJob) {
let unownedJob = UnownedJob(job)
let executor = asUnownedSerialExecutor()
context.perform {
unownedJob.runSynchronously(on: executor)
}
}
func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(ordinary: self)
}
}
```
### Actor usage
```swift
actor CoreDataStore {
let persistentContainer: NSPersistentContainer
private let context: NSManagedObjectContext
nonisolated let modelExecutor: NSManagedObjectContextExecutor
nonisolated var unownedExecutor: UnownedSerialExecutor {
modelExecutor.asUnownedSerialExecutor()
}
private init() {
persistentContainer = NSPersistentContainer(name: "MyApp")
context = persistentContainer.newBackgroundContext()
modelExecutor = NSManagedObjectContextExecutor(context: context)
}
func deleteAll<T: NSManagedObject>(
using request: NSFetchRequest<T>
) throws {
let objects = try context.fetch(request)
objects.forEach { context.delete($0) }
try context.save()
}
}
```
### Drawbacks
- **Hidden complexity**: Executor details obscure Core Data
- **Forces concurrency**: Even for main thread operations
- **Not simpler**: More code than `perform { }`
- **Error prone**: Easy to use wrong context
**Recommendation**: Use simple pattern instead.
## Default MainActor Isolation
### Problem with auto-generated code
When default isolation set to `@MainActor`, auto-generated managed objects conflict:
```swift
// Auto-generated (can't modify)
class Article: NSManagedObject {
// Inherits @MainActor, conflicts with NSManagedObject
}
```
**Error**: `Main actor-isolated initializer has different actor isolation from nonisolated overridden declaration`
### Solution: Manual code generation
1. Set entity to "Manual/None" code generation
2. Generate class definitions
3. Mark as `nonisolated`:
```swift
nonisolated class Article: NSManagedObject {
@NSManaged public var title: String?
@NSManaged public var timestamp: Date?
}
```
**Benefit**: Full control over isolation.
## Common Patterns
### Fetch on main thread
```swift
@MainActor
func fetchArticles() throws -> [Article] {
let request = Article.fetchRequest()
return try viewContext.fetch(request)
}
```
### Background save
```swift
func saveInBackground() async throws {
let context = container.newBackgroundContext()
try await context.perform {
let article = Article(context: context)
article.title = "New Article"
try context.save()
}
}
```
### Pass ID, fetch in context
```swift
@MainActor
func displayArticle(id: NSManagedObjectID) {
guard let article = viewContext.object(with: id) as? Article else {
return
}
// Use article
}
func processArticle(id: NSManagedObjectID) async throws {
let context = container.newBackgroundContext()
try await context.perform {
guard let article = context.object(with: id) as? Article else { return }
// Process article
try context.save()
}
}
```
### Batch operations
```swift
func deleteAllArticles() async throws {
let context = container.newBackgroundContext()
try await context.perform {
let request = NSFetchRequest<NSFetchRequestResult>(entityName: "Article")
let deleteRequest = NSBatchDeleteRequest(fetchRequest: request)
try context.execute(deleteRequest)
}
}
```
## SwiftUI Integration
### Environment injection
```swift
@main
struct MyApp: App {
let persistentContainer = NSPersistentContainer(name: "MyApp")
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, persistentContainer.viewContext)
}
}
}
```
### View usage
```swift
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Article.timestamp, ascending: true)]
) private var articles: FetchedResults<Article>
var body: some View {
List(articles) { article in
Text(article.title ?? "")
}
}
}
```
## Best Practices
1. **Pass NSManagedObjectID only** - never managed objects
2. **Use perform { }** - don't access context directly
3. **@MainActor for view context** - enforce main thread
4. **Use background contexts** - run heavy work off the main thread
5. **Manual code generation** - control isolation
6. **Keep it simple** - avoid custom executors unless needed
7. **Enable Core Data debugging** - catch thread violations
8. **Merge changes automatically** - `automaticallyMergesChangesFromParent = true`
9. **Use background contexts** - for heavy operations
10. **Test with Thread Sanitizer** - catch violations early
## Debugging
### Enable Core Data concurrency debugging
```swift
// Launch argument
-com.apple.CoreData.ConcurrencyDebug 1
```
Crashes immediately on thread violations.
### Thread Sanitizer
Enable in scheme settings to catch data races.
### Assertions
```swift
@MainActor
func fetchArticles() -> [Article] {
assert(Thread.isMainThread)
// Fetch from viewContext
}
```
## Decision Tree
```
Need to access Core Data?
├─ UI/View context?
│ └─ Use @MainActor + viewContext
├─ Background operation?
│ ├─ Quick operation? → perform { } on background context
│ └─ Batch operation? → NSBatchDeleteRequest/NSBatchUpdateRequest
├─ Pass between contexts?
│ └─ Use NSManagedObjectID only
└─ Need Sendable type?
├─ Can refactor? → Use DAO pattern
└─ Can't refactor? → Pass NSManagedObjectID
```
## Migration Strategy
### For existing projects
1. **Enable manual code generation** for all entities
2. **Mark entities as nonisolated** if using default @MainActor
3. **Wrap Core Data access** in CoreDataStore
4. **Use @MainActor** for view context operations
5. **Use background contexts** for write-heavy work
6. **Pass NSManagedObjectID** between contexts
7. **Test with debugging enabled**
### For new projects
1. **Start with simple pattern** (CoreDataStore)
2. **Manual code generation** from the start
3. **Consider DAOs** if heavy cross-context usage
4. **Enable strict concurrency** early
## Common Mistakes
### ❌ Passing managed objects
```swift
func process(article: Article) async {
// Article not Sendable
}
```
### ❌ Accessing context from wrong thread
```swift
func background() async {
let articles = viewContext.fetch(request) // Not on main thread
}
```
### ❌ Using @unchecked Sendable
```swift
extension Article: @unchecked Sendable {} // Doesn't make it safe
```
### ❌ Not using perform
```swift
func save() async {
backgroundContext.save() // Not on context's thread
}
```
## Related References
- See `threading.md` for general Core Data threading patterns
- See `batch-operations.md` for async batch operation patterns
- See `stack-setup.md` for container setup with async/await
- See `testing.md` for testing async Core Data code
## Further Learning
For Core Data best practices, migration strategies, and advanced patterns:
- [Core Data Best Practices Repository](https://github.com/avanderlee/CoreDataBestPractices)
- [Swift Concurrency Course](https://www.swiftconcurrencycourse.com)

View File

@@ -0,0 +1,643 @@
# Fetch Requests and Querying
Optimizing fetch requests is crucial for app performance. This guide covers best practices for querying Core Data efficiently, from basic fetches to advanced aggregations.
## Basic Fetch Request
```swift
let fetchRequest: NSFetchRequest<Article> = Article.fetchRequest()
let articles = try context.fetch(fetchRequest)
```
## Optimization Strategies
### 1. Limit Properties Fetched
Only fetch the properties you actually need:
```swift
let fetchRequest = Article.fetchRequest()
fetchRequest.propertiesToFetch = ["name", "creationDate"]
// For list view, you might only need:
fetchRequest.propertiesToFetch = ["name", "categoryName", "views"]
```
**SQL Impact:**
```sql
-- Without propertiesToFetch
SELECT * FROM ZARTICLE
-- With propertiesToFetch
SELECT Z_PK, ZNAME, ZCREATIONDATE FROM ZARTICLE
```
**Benefits:**
- Reduces memory usage
- Faster query execution
- Less data transferred from disk
### 2. Use Batch Fetching
Fetch objects in batches to avoid loading everything at once:
```swift
let fetchRequest = Article.fetchRequest()
fetchRequest.fetchBatchSize = 20
```
**How it works:**
- Initially fetches only 20 objects
- Fetches next batch when needed (scrolling, iteration)
- Keeps memory usage predictable
**When to use:**
- List views (table/collection views)
- Large datasets
- Scrollable content
### 3. Set Fetch Limit
When you only need a specific number of results:
```swift
let fetchRequest = Article.fetchRequest()
fetchRequest.fetchLimit = 1 // Only fetch one result
```
**Common use cases:**
```swift
// Get newest article
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
fetchRequest.fetchLimit = 1
// Get top 10 most viewed
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "views", ascending: false)]
fetchRequest.fetchLimit = 10
```
### 4. Fetch Only Object IDs
For counting or checking existence, fetch only IDs:
```swift
let fetchRequest = Article.fetchRequest()
fetchRequest.resultType = .managedObjectIDResultType
let objectIDs = try context.fetch(fetchRequest) as! [NSManagedObjectID]
```
**Benefits:**
- Minimal memory usage
- Very fast
- No faulting overhead
**Use for:**
- Counting objects
- Checking existence
- Batch operations
- Validation
## Sort Descriptors
Always specify sort descriptors for predictable results:
```swift
let fetchRequest = Article.fetchRequest()
fetchRequest.sortDescriptors = [
NSSortDescriptor(key: "creationDate", ascending: false)
]
```
### Multiple Sort Descriptors
```swift
fetchRequest.sortDescriptors = [
NSSortDescriptor(key: "category.name", ascending: true),
NSSortDescriptor(key: "name", ascending: true)
]
```
### Case-Insensitive Sorting
```swift
let sortDescriptor = NSSortDescriptor(
key: "name",
ascending: true,
selector: #selector(NSString.caseInsensitiveCompare(_:))
)
fetchRequest.sortDescriptors = [sortDescriptor]
```
### Localized Sorting
```swift
let sortDescriptor = NSSortDescriptor(
key: "name",
ascending: true,
selector: #selector(NSString.localizedStandardCompare(_:))
)
fetchRequest.sortDescriptors = [sortDescriptor]
```
## Predicates
Filter results using predicates:
### Basic Predicates
```swift
// Exact match
fetchRequest.predicate = NSPredicate(format: "name == %@", "SwiftLee")
// Contains
fetchRequest.predicate = NSPredicate(format: "name CONTAINS[cd] %@", "swift")
// [c] = case insensitive, [d] = diacritic insensitive
// Begins with
fetchRequest.predicate = NSPredicate(format: "name BEGINSWITH[c] %@", "Swift")
// Greater than
fetchRequest.predicate = NSPredicate(format: "views > %d", 100)
// Date range
let startDate = Calendar.current.startOfDay(for: Date())
let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)!
fetchRequest.predicate = NSPredicate(
format: "creationDate >= %@ AND creationDate < %@",
startDate as NSDate,
endDate as NSDate
)
```
### Compound Predicates
```swift
// AND
let predicate1 = NSPredicate(format: "views > %d", 100)
let predicate2 = NSPredicate(format: "category.name == %@", "Swift")
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate1, predicate2])
// OR
fetchRequest.predicate = NSCompoundPredicate(orPredicateWithSubpredicates: [predicate1, predicate2])
// NOT
fetchRequest.predicate = NSCompoundPredicate(notPredicateWithSubpredicate: predicate1)
```
### Relationship Predicates
```swift
// Articles with a specific category
fetchRequest.predicate = NSPredicate(format: "category.name == %@", "Swift")
// Articles with any attachments
fetchRequest.predicate = NSPredicate(format: "attachments.@count > 0")
// Articles with more than 5 attachments
fetchRequest.predicate = NSPredicate(format: "attachments.@count > 5")
// Using ANY
fetchRequest.predicate = NSPredicate(format: "ANY attachments.size > %d", 1000000)
// Using ALL
fetchRequest.predicate = NSPredicate(format: "ALL attachments.isDownloaded == YES")
```
### IN Predicate
```swift
let names = ["Swift", "iOS", "Core Data"]
fetchRequest.predicate = NSPredicate(format: "name IN %@", names)
```
## NSFetchedResultsController
For table and collection views, use `NSFetchedResultsController` for automatic updates:
```swift
class ArticlesViewController: UIViewController {
var fetchedResultsController: NSFetchedResultsController<Article>!
func setupFetchedResultsController() {
let fetchRequest = Article.fetchRequest()
fetchRequest.sortDescriptors = [
NSSortDescriptor(key: "creationDate", ascending: false)
]
fetchRequest.fetchBatchSize = 20
fetchedResultsController = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: viewContext,
sectionNameKeyPath: nil,
cacheName: "ArticlesCache"
)
fetchedResultsController.delegate = self
try? fetchedResultsController.performFetch()
}
}
```
### With Sections
```swift
fetchedResultsController = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: viewContext,
sectionNameKeyPath: "category.name", // Group by category
cacheName: "ArticlesByCategoryCache"
)
```
### Delegate Methods (UITableView)
```swift
extension ArticlesViewController: NSFetchedResultsControllerDelegate {
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
tableView.beginUpdates()
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChange anObject: Any,
at indexPath: IndexPath?,
for type: NSFetchedResultsChangeType,
newIndexPath: IndexPath?) {
switch type {
case .insert:
if let indexPath = newIndexPath {
tableView.insertRows(at: [indexPath], with: .automatic)
}
case .delete:
if let indexPath = indexPath {
tableView.deleteRows(at: [indexPath], with: .automatic)
}
case .update:
if let indexPath = indexPath {
tableView.reloadRows(at: [indexPath], with: .automatic)
}
case .move:
if let indexPath = indexPath, let newIndexPath = newIndexPath {
tableView.deleteRows(at: [indexPath], with: .automatic)
tableView.insertRows(at: [newIndexPath], with: .automatic)
}
@unknown default:
break
}
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
tableView.endUpdates()
}
}
```
## Diffable Data Sources (iOS 13+)
Modern approach using `NSDiffableDataSourceSnapshot`:
```swift
class ArticlesViewController: UICollectionViewController {
private var dataSource: UICollectionViewDiffableDataSource<String, NSManagedObjectID>!
private var fetchedResultsController: NSFetchedResultsController<Article>!
func setupDataSource() {
dataSource = UICollectionViewDiffableDataSource<String, NSManagedObjectID>(
collectionView: collectionView
) { collectionView, indexPath, objectID in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: "ArticleCell",
for: indexPath
) as! ArticleCell
if let article = try? self.viewContext.existingObject(with: objectID) as? Article {
cell.configure(with: article)
}
return cell
}
}
func setupFetchedResultsController() {
let fetchRequest = Article.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
fetchedResultsController = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: viewContext,
sectionNameKeyPath: nil,
cacheName: nil
)
fetchedResultsController.delegate = self
try? fetchedResultsController.performFetch()
}
}
extension ArticlesViewController: NSFetchedResultsControllerDelegate {
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
let snapshot = snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>
dataSource.apply(snapshot, animatingDifferences: true)
}
}
```
## Aggregate Fetching with NSExpression
For statistics and aggregations:
### Count
```swift
// Simple count
let count = try context.count(for: Article.fetchRequest())
// Count with predicate
let fetchRequest = Article.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "views > %d", 100)
let count = try context.count(for: fetchRequest)
```
### Sum, Average, Min, Max
```swift
let fetchRequest = Article.fetchRequest()
fetchRequest.resultType = .dictionaryResultType
// Sum of views
let sumExpression = NSExpression(format: "@sum.views")
let sumDescription = NSExpressionDescription()
sumDescription.name = "totalViews"
sumDescription.expression = sumExpression
sumDescription.expressionResultType = .integer64AttributeType
fetchRequest.propertiesToFetch = [sumDescription]
let results = try context.fetch(fetchRequest) as! [[String: Any]]
if let totalViews = results.first?["totalViews"] as? Int {
print("Total views: \(totalViews)")
}
```
### Group By with Aggregates
```swift
let fetchRequest = Article.fetchRequest()
fetchRequest.resultType = .dictionaryResultType
// Category name
let categoryExpression = NSExpression(forKeyPath: "category.name")
let categoryDescription = NSExpressionDescription()
categoryDescription.name = "categoryName"
categoryDescription.expression = categoryExpression
categoryDescription.expressionResultType = .stringAttributeType
// Sum of views per category
let sumExpression = NSExpression(format: "@sum.views")
let sumDescription = NSExpressionDescription()
sumDescription.name = "totalViews"
sumDescription.expression = sumExpression
sumDescription.expressionResultType = .integer64AttributeType
fetchRequest.propertiesToFetch = [categoryDescription, sumDescription]
fetchRequest.propertiesToGroupBy = ["category.name"]
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "categoryName", ascending: true)]
let results = try context.fetch(fetchRequest) as! [[String: Any]]
for result in results {
let category = result["categoryName"] as? String ?? "Unknown"
let views = result["totalViews"] as? Int ?? 0
print("\(category): \(views) views")
}
```
### Count Per Group
```swift
let fetchRequest = Article.fetchRequest()
fetchRequest.resultType = .dictionaryResultType
let categoryExpression = NSExpression(forKeyPath: "category.name")
let categoryDescription = NSExpressionDescription()
categoryDescription.name = "categoryName"
categoryDescription.expression = categoryExpression
categoryDescription.expressionResultType = .stringAttributeType
let countExpression = NSExpression(forFunction: "count:", arguments: [NSExpression(forKeyPath: "objectID")])
let countDescription = NSExpressionDescription()
countDescription.name = "count"
countDescription.expression = countExpression
countDescription.expressionResultType = .integer64AttributeType
fetchRequest.propertiesToFetch = [categoryDescription, countDescription]
fetchRequest.propertiesToGroupBy = ["category.name"]
let results = try context.fetch(fetchRequest) as! [[String: Any]]
```
## Typed Fetch Requests with Managed Protocol
Create a protocol for type-safe fetch requests:
```swift
protocol Managed: NSManagedObject {
static var entityName: String { get }
}
extension Managed {
static var entityName: String {
return String(describing: self)
}
static func fetchRequest<T: NSManagedObject>() -> NSFetchRequest<T> {
return NSFetchRequest<T>(entityName: entityName)
}
}
// Conform your entities
extension Article: Managed {}
// Usage
let fetchRequest: NSFetchRequest<Article> = Article.fetchRequest()
```
## Asynchronous Fetching
For large datasets, fetch asynchronously:
```swift
let fetchRequest = Article.fetchRequest()
let asyncFetchRequest = NSAsynchronousFetchRequest(fetchRequest: fetchRequest) { result in
guard let articles = result.finalResult else { return }
DispatchQueue.main.async {
// Update UI with articles
}
}
try? context.execute(asyncFetchRequest)
```
## Faulting Control
### Prefetching Relationships
```swift
let fetchRequest = Article.fetchRequest()
fetchRequest.relationshipKeyPathsForPrefetching = ["category", "attachments"]
```
**Benefits:**
- Reduces number of database trips
- Improves performance when accessing relationships
- Prevents N+1 query problem
### Returning Faults
```swift
fetchRequest.returnsObjectsAsFaults = false
```
**When to use:**
- You know you'll access all properties immediately
- Small result sets
- Avoid for large datasets (high memory usage)
## Common Patterns
### Fetch Single Object by ID
```swift
func fetchArticle(withID id: NSManagedObjectID) -> Article? {
return try? context.existingObject(with: id) as? Article
}
```
### Fetch or Create
```swift
func fetchOrCreateArticle(withName name: String) -> Article {
let fetchRequest = Article.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name == %@", name)
fetchRequest.fetchLimit = 1
if let existing = try? context.fetch(fetchRequest).first {
return existing
}
let article = Article(context: context)
article.name = name
return article
}
```
### Check Existence
```swift
func articleExists(withName name: String) -> Bool {
let fetchRequest = Article.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name == %@", name)
fetchRequest.fetchLimit = 1
fetchRequest.resultType = .countResultType
let count = (try? context.count(for: fetchRequest)) ?? 0
return count > 0
}
```
## Performance Tips
### ❌ Don't Fetch Everything
```swift
// Bad: Fetches all properties, all objects
let articles = try context.fetch(Article.fetchRequest())
let count = articles.count
```
### ✅ Use Count Request
```swift
// Good: Only counts, doesn't fetch objects
let count = try context.count(for: Article.fetchRequest())
```
### ❌ Don't Access Relationships in Loops
```swift
// Bad: Fires fault for each article
for article in articles {
print(article.category?.name) // Fault!
}
```
### ✅ Prefetch Relationships
```swift
// Good: Prefetches all categories at once
let fetchRequest = Article.fetchRequest()
fetchRequest.relationshipKeyPathsForPrefetching = ["category"]
let articles = try context.fetch(fetchRequest)
for article in articles {
print(article.category?.name) // No fault!
}
```
### ❌ Don't Fetch in Loops
```swift
// Bad: Multiple fetch requests
for name in names {
let fetchRequest = Article.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name == %@", name)
let articles = try? context.fetch(fetchRequest)
}
```
### ✅ Use IN Predicate
```swift
// Good: Single fetch request
let fetchRequest = Article.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name IN %@", names)
let articles = try context.fetch(fetchRequest)
```
## Debugging Fetch Requests
### Enable SQL Debug
Add launch argument:
```
-com.apple.CoreData.SQLDebug 1
```
**Output:**
```sql
CoreData: sql: SELECT Z_PK, ZNAME, ZVIEWS FROM ZARTICLE WHERE ZVIEWS > ? ORDER BY ZCREATIONDATE DESC LIMIT 20
```
### Measure Fetch Performance
```swift
let startTime = CFAbsoluteTimeGetCurrent()
let articles = try context.fetch(fetchRequest)
let timeElapsed = CFAbsoluteTimeGetCurrent() - startTime
print("Fetch took \(timeElapsed) seconds")
```
## Summary
1. **Use `propertiesToFetch`** to limit fetched properties
2. **Set `fetchBatchSize`** for large datasets (typically 20-50)
3. **Use `fetchLimit`** when you only need a few results
4. **Always specify sort descriptors** for predictable results
5. **Use predicates** to filter at the database level
6. **Use `NSFetchedResultsController`** for list views
7. **Prefetch relationships** to avoid N+1 queries
8. **Use count requests** instead of fetching for counts
9. **Use aggregate expressions** for statistics
10. **Enable SQL debug** to understand query performance

View File

@@ -0,0 +1,233 @@
# Core Data Glossary
Quick reference for Core Data terminology.
## Core Concepts
**Core Data**
Apple's framework for object graph management and persistence.
**Persistent Store**
The underlying storage (typically SQLite database) where data is saved.
**Managed Object Model**
Describes your data schema (entities, attributes, relationships).
**Entity**
A class definition in your data model (like a database table).
**Attribute**
A property of an entity (like a database column).
**Relationship**
A connection between entities (one-to-one, one-to-many, many-to-many).
## Stack Components
**NSPersistentContainer**
Encapsulates the Core Data stack (model, coordinator, contexts).
**NSPersistentCloudKitContainer**
Extends NSPersistentContainer with CloudKit sync capabilities.
**NSPersistentStoreCoordinator**
Manages one or more persistent stores and coordinates access.
**NSManagedObjectContext**
Scratch pad for working with managed objects. Changes aren't persisted until saved.
**NSManagedObject**
Base class for Core Data objects. Represents a row in a database table.
**NSManagedObjectID**
Unique, immutable identifier for a managed object. Thread-safe.
## Context Types
**View Context**
Main queue context for UI operations. Runs on main thread.
**Background Context**
Private queue context for heavy work. Runs on background thread.
**Child Context**
Context with a parent context. Saves push changes to parent, not to disk.
## Fetching
**NSFetchRequest**
Describes a search for objects in the persistent store.
**NSFetchedResultsController**
Manages fetch results for table/collection views with automatic updates.
**Predicate**
Filter condition for fetch requests (like SQL WHERE clause).
**Sort Descriptor**
Defines ordering for fetch results (like SQL ORDER BY).
**Faulting**
Lazy loading mechanism. Object data loaded only when accessed.
**Prefetching**
Loading related objects eagerly to avoid faulting.
## Operations
**Save**
Persists changes from context to persistent store.
**Fetch**
Retrieves objects from persistent store.
**Insert**
Creates new object in context.
**Delete**
Marks object for deletion. Removed on save.
**Refresh**
Reloads object from persistent store, discarding in-memory changes.
**Reset**
Clears all objects from context, freeing memory.
**Rollback**
Discards all unsaved changes in context.
## Batch Operations
**NSBatchInsertRequest**
Inserts multiple objects at SQL level (iOS 14+).
**NSBatchDeleteRequest**
Deletes multiple objects at SQL level.
**NSBatchUpdateRequest**
Updates multiple objects at SQL level.
## Advanced Features
**Persistent History Tracking**
Records all changes in a transaction log for cross-context synchronization.
**Derived Attribute**
Computed attribute stored in database (e.g., `articles.@count`).
**Transformable**
Custom type stored using value transformer.
**Constraint**
Ensures attribute uniqueness (requires merge policy).
**Merge Policy**
Determines how conflicts are resolved when saving.
## Migration
**Lightweight Migration**
Automatic migration for simple model changes.
**Staged Migration**
Complex migration decomposed into steps (iOS 17+).
**Deferred Migration**
Delays cleanup work for better performance (iOS 14+).
**Composite Attribute**
Structured data within single attribute (iOS 17+).
**Mapping Model**
Describes how to migrate from one model version to another.
**Version Hash**
Checksum identifying a specific model version.
## Threading
**perform**
Executes block asynchronously on context's queue.
**performAndWait**
Executes block synchronously on context's queue (blocks calling thread).
**Thread Confinement**
Each context must be accessed only from its queue.
**automaticallyMergesChangesFromParent**
Context automatically receives changes from parent context.
## Validation
**validateForInsert**
Called before inserting object.
**validateForUpdate**
Called before updating object.
**validateForDelete**
Called before deleting object.
## Lifecycle
**awakeFromInsert**
Called once when object first inserted.
**awakeFromFetch**
Called when object loaded from store.
**willSave**
Called before each save.
**didSave**
Called after save completes.
**prepareForDeletion**
Called when object marked for deletion.
## CloudKit
**Container Identifier**
Unique ID for CloudKit container (e.g., `iCloud.com.example.app`).
**Development Environment**
CloudKit environment for testing (schema mutable).
**Production Environment**
CloudKit environment for released apps (schema immutable).
**Schema Initialization**
First run creates CloudKit schema from Core Data model.
**Event Notification**
Notification sent when CloudKit sync events occur.
## Debugging
**SQL Debug**
Launch argument to log SQL queries: `-com.apple.CoreData.SQLDebug 1`
**Concurrency Debug**
Launch argument to catch threading violations: `-com.apple.CoreData.ConcurrencyDebug 1`
**Migration Debug**
Launch argument to log migration steps: `-com.apple.CoreData.MigrationDebug 1`
## Common Acronyms
**CD** - Core Data
**MOC** - Managed Object Context (NSManagedObjectContext)
**MO** - Managed Object (NSManagedObject)
**FRC** - Fetched Results Controller (NSFetchedResultsController)
**PSC** - Persistent Store Coordinator (NSPersistentStoreCoordinator)
**MOD** - Managed Object Model (NSManagedObjectModel)
## Quick Reference
**Thread-safe:** NSManagedObjectID, NSPersistentStoreCoordinator
**Not thread-safe:** NSManagedObject, NSManagedObjectContext
**Main thread only:** View context operations
**Background thread:** Background context operations
**Automatic:** Lightweight migration (with NSPersistentContainer)
**Manual:** Staged migration, custom mapping models

View File

@@ -0,0 +1,393 @@
# Schema Migration
Schema migration is the process of updating your Core Data model as your app evolves. Core Data provides three migration strategies: lightweight, staged (iOS 17+), and deferred (iOS 14+).
## When Migration is Required
Core Data refuses to open a store when the model doesn't match:
```
Error: NSPersistentStoreIncompatibleVersionHashError
```
**This means:** Your data model changed, and you need to migrate.
## Lightweight Migration (Recommended)
Lightweight migration is automatic and handles most common changes.
### Enabling Lightweight Migration
With `NSPersistentContainer` (automatic):
```swift
let container = NSPersistentContainer(name: "Model")
// Lightweight migration enabled by default
```
With `NSPersistentStoreDescription` (automatic):
```swift
let description = NSPersistentStoreDescription(url: storeURL)
// Lightweight migration enabled by default
```
Manual setup (if needed):
```swift
let options = [
NSMigratePersistentStoresAutomaticallyOption: true,
NSInferMappingModelAutomaticallyOption: true
]
try coordinator.addPersistentStore(
ofType: NSSQLiteStoreType,
configurationName: nil,
at: storeURL,
options: options
)
```
### Supported Operations
**Attributes:**
- Add attribute
- Remove attribute
- Make optional attribute non-optional (with default value)
- Make non-optional attribute optional
- Rename attribute (using renaming identifier)
**Relationships:**
- Add relationship
- Remove relationship
- Rename relationship (using renaming identifier)
- Change cardinality (to-one ↔ to-many)
- Change ordering (ordered ↔ non-ordered)
**Entities:**
- Add entity
- Remove entity
- Rename entity (using renaming identifier)
- Create parent/child entity
- Move attributes up/down hierarchy
- Move entities in/out of hierarchy
**Cannot do:**
- Merge entity hierarchies (entities without common parent can't share parent)
### Renaming Attributes/Entities
Set the renaming identifier to the **old name**:
```swift
// In Data Model Editor:
// 1. Rename attribute from "color" to "paintColor"
// 2. Set Renaming Identifier to "color"
```
This allows chaining renames across versions:
- V1: `color`
- V2: `paintColor` (renaming ID: `color`)
- V3: `primaryColor` (renaming ID: `paintColor`)
Migration works: V1→V2, V2→V3, and V1→V3.
### Testing Lightweight Migration
```swift
// Check if migration is possible
let sourceModel = // ... load V1 model
let destinationModel = // ... load V2 model
if let mappingModel = try? NSMappingModel.inferredMappingModel(
forSourceModel: sourceModel,
destinationModel: destinationModel
) {
print("Lightweight migration possible")
} else {
print("Lightweight migration not possible")
}
```
## Composite Attributes (iOS 17+)
New in iOS 17: Structured data within a single attribute.
### Creating Composite Attributes
In Data Model Editor:
1. Add Composite Attribute
2. Add elements (String, Int, Date, etc.)
3. Can nest composite attributes
```swift
// Example: ColorScheme composite
// - primary: String
// - secondary: String
// - tertiary: String
class Aircraft: NSManagedObject {
@NSManaged var colorScheme: [String: Any]
}
// Usage
aircraft.colorScheme = [
"primary": "Red",
"secondary": "White",
"tertiary": "Blue"
]
// Querying
fetchRequest.predicate = NSPredicate(format: "colorScheme.primary == %@", "Red")
```
### Benefits
- No transformable code needed
- Supports predicates with keypaths
- Better than flattened attributes
- Can prevent faulting across relationships
## Staged Migration (iOS 17+)
For complex migrations that exceed lightweight capabilities.
### When to Use
- Changes don't fit lightweight patterns
- Need to run custom code during migration
- Need to decompose complex changes into steps
### Key Classes
- `NSStagedMigrationManager` - Manages migration event loop
- `NSCustomMigrationStage` - Custom code execution
- `NSLightweightMigrationStage` - Lightweight-eligible changes
- `NSManagedObjectModelReference` - Model references with checksums
### Example: Denormalizing Data
**Problem:** Move `flightData` attribute to separate entity.
**Solution:** Decompose into stages:
**Stage 1 (Lightweight):** Add new entity and relationship
```swift
// ModelV1 ModelV2
// Add FlightData entity
// Add flightParameters relationship to Aircraft
```
**Stage 2 (Custom):** Copy data
- Fetch rows using generic `NSManagedObject` / `NSFetchRequestResult` types.
- Create new entities and copy data inside the migration stage handler.
- Ensure the custom logic is restartable if the process is interrupted.
**Stage 3 (Lightweight):** Remove old attribute
```swift
// ModelV3 ModelV4
// Remove flightData attribute from Aircraft
```
### Getting Version Checksum
From Xcode build log:
```
Compile data model Model.xcdatamodeld
version checksum: ABC123...
```
## Deferred Migration (iOS 14+)
Defer cleanup work to keep app responsive.
### When to Use
- Removing attributes/relationships
- Changing relationship hierarchy
- Changing relationship ordering
- Any migration with expensive cleanup
### How It Works
1. Migration runs synchronously (fast)
2. Cleanup (indices, column drops) is deferred
3. App uses latest schema immediately
4. Finish cleanup when resources available
### Enabling Deferred Migration
```swift
let description = NSPersistentStoreDescription(url: storeURL)
description.setOption(
true as NSNumber,
forKey: NSPersistentStoreDeferredLightweightMigrationOptionKey
)
```
### Checking for Pending Work
```swift
let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(
ofType: NSSQLiteStoreType,
at: storeURL
)
if let hasDeferredWork = metadata[NSPersistentStoreDeferredLightweightMigrationOptionKey] as? Bool,
hasDeferredWork {
print("Deferred migration work pending")
}
```
### Finishing Deferred Migration
```swift
func finishDeferredMigration() {
let coordinator = container.persistentStoreCoordinator
do {
try coordinator.finishDeferredLightweightMigration()
print("Deferred migration completed")
} catch {
print("Failed to finish deferred migration: \(error)")
}
}
```
### Scheduling with Background Tasks
```swift
import BackgroundTasks
// Register task
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.example.app.migration",
using: nil
) { task in
self.handleMigrationTask(task as! BGProcessingTask)
}
// Schedule task
func scheduleMigration() {
let request = BGProcessingTaskRequest(identifier: "com.example.app.migration")
request.requiresNetworkConnectivity = false
request.requiresExternalPower = false
try? BGTaskScheduler.shared.submit(request)
}
// Handle task
func handleMigrationTask(_ task: BGProcessingTask) {
task.expirationHandler = {
task.setTaskCompleted(success: false)
}
finishDeferredMigration()
task.setTaskCompleted(success: true)
}
```
## Migration Debugging
### Enable Migration Debug
```
-com.apple.CoreData.MigrationDebug 1
```
**Output:**
```
CoreData: annotation: Migration: Migrating from version 1 to version 2
CoreData: annotation: Migration: Inferred mapping model
CoreData: annotation: Migration: Completed successfully
```
### Common Errors
**NSPersistentStoreIncompatibleVersionHashError**
- Model changed, migration required
- Enable lightweight migration or create mapping model
**NSMigrationMissingSourceModelError**
- Can't find source model
- Ensure all model versions are in bundle
**NSMigrationError**
- Migration failed
- Check if changes are lightweight-compatible
- Use staged migration for complex changes
## Best Practices
1. **Test migrations thoroughly** - Test upgrade paths from all previous versions
2. **Keep model versions** - Don't delete old .xcdatamodel files
3. **Use lightweight when possible** - Simplest and most reliable
4. **Decompose complex changes** - Use staged migration for non-lightweight changes
5. **Defer expensive cleanup** - Use deferred migration for large datasets
6. **Version your models** - Create new model version for each release
7. **Test on real data** - Migration behavior differs with large datasets
8. **Document changes** - Keep migration notes for future reference
## Testing Migrations
```swift
func testMigration() throws {
// 1. Create store with old model
let oldModelURL = Bundle.main.url(forResource: "ModelV1", withExtension: "momd")!
let oldModel = NSManagedObjectModel(contentsOf: oldModelURL)!
let coordinator = NSPersistentStoreCoordinator(managedObjectModel: oldModel)
try coordinator.addPersistentStore(
ofType: NSSQLiteStoreType,
configurationName: nil,
at: storeURL,
options: nil
)
// 2. Add test data
let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
context.persistentStoreCoordinator = coordinator
let entity = NSEntityDescription.insertNewObject(forEntityName: "Article", into: context)
entity.setValue("Test", forKey: "name")
try context.save()
// 3. Close store
try coordinator.remove(coordinator.persistentStores.first!)
// 4. Migrate with new model
let newModelURL = Bundle.main.url(forResource: "ModelV2", withExtension: "momd")!
let newModel = NSManagedObjectModel(contentsOf: newModelURL)!
let newCoordinator = NSPersistentStoreCoordinator(managedObjectModel: newModel)
let options = [
NSMigratePersistentStoresAutomaticallyOption: true,
NSInferMappingModelAutomaticallyOption: true
]
try newCoordinator.addPersistentStore(
ofType: NSSQLiteStoreType,
configurationName: nil,
at: storeURL,
options: options
)
// 5. Verify data
let newContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
newContext.persistentStoreCoordinator = newCoordinator
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: "Article")
let results = try newContext.fetch(fetchRequest)
XCTAssertEqual(results.count, 1)
XCTAssertEqual(results.first?.value(forKey: "name") as? String, "Test")
}
```
## Summary
1. **Use lightweight migration** - Handles most common changes automatically
2. **Enable by default** - NSPersistentContainer enables it automatically
3. **Use renaming identifiers** - For renaming attributes/entities/relationships
4. **Use composite attributes (iOS 17+)** - For structured data
5. **Use staged migration (iOS 17+)** - For complex, non-lightweight changes
6. **Use deferred migration (iOS 14+)** - For expensive cleanup operations
7. **Test thoroughly** - Verify all upgrade paths
8. **Keep all model versions** - Required for migration
9. **Enable migration debug** - Helps diagnose issues
10. **Document changes** - Track what changed in each version

View File

@@ -0,0 +1,597 @@
# Model Configuration
Core Data's data model offers powerful configuration options beyond basic attributes and relationships. This guide covers constraints, derived attributes, transformables, validation, and lifecycle events.
## Constraints
Constraints ensure uniqueness of attribute values. When combined with the correct merge policy, Core Data automatically handles duplicates.
### Setting Up Constraints
In Xcode's Data Model Editor:
1. Select your entity
2. In the Data Model Inspector, find "Constraints"
3. Click "+" and add attribute names
**Example:** Make `name` unique in the `Category` entity.
### Required Merge Policy
```swift
viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
```
**Without this merge policy, constraint violations will crash your app.**
### How Constraints Work
```swift
// First save
let category1 = Category(context: context)
category1.name = "Swift"
try context.save() // Saves successfully
// Duplicate attempt
let category2 = Category(context: context)
category2.name = "Swift" // Same name
try context.save() // With correct merge policy: keeps first, discards second
```
### Multiple Constraints
```swift
// Constraints on multiple attributes
// In model: constraints = ["email", "username"]
// Both must be unique
user1.email = "test@example.com"
user1.username = "testuser"
```
### Compound Constraints
```swift
// Unique combination of attributes
// In model: constraints = ["firstName,lastName"]
// These are different (unique combinations)
person1.firstName = "John"
person1.lastName = "Doe"
person2.firstName = "John"
person2.lastName = "Smith" // Different combination, allowed
```
## Derived Attributes
Derived attributes are computed from other attributes or relationships and stored in the database. They're calculated on save or refresh.
### Benefits
- No need to manually update computed values
- Better performance than accessing relationships
- Optimized for queries
### Common Derivations
#### 1. Count of Relationships
```swift
// In Data Model Editor:
// Derived attribute: articlesCount
// Derivation: articles.@count
```
**Why this is better than `articles.count`:**
- Doesn't fire faults
- Faster queries
- Always up-to-date after save
#### 2. Related Object Property
```swift
// Derived attribute: categoryName
// Derivation: category.name
```
**Use case:** Avoid firing faults when displaying list views.
#### 3. Current Timestamp
```swift
// Derived attribute: lastModified
// Derivation: now()
```
**Automatically updates on every save.**
#### 4. Canonical String (Search Optimization)
```swift
// Derived attribute: searchName
// Derivation: canonical:(name)
```
**What it does:**
- Converts to lowercase
- Removes diacritics
- Perfect for case-insensitive, diacritic-insensitive searches
**Example:**
```swift
// name = "Café"
// searchName = "cafe"
// Search query
fetchRequest.predicate = NSPredicate(format: "searchName CONTAINS %@", "cafe")
// Matches "Café", "CAFE", "café", etc.
```
#### 5. Sum of Related Values
```swift
// Derived attribute: totalViews
// Derivation: @sum.articles.views
```
### Important Notes
- Derived attributes are calculated **on save** or **refresh**
- In-memory changes don't update derived attributes until saved
- Can't be set manually (they're computed)
### Example Usage
```swift
class Article: NSManagedObject {
@NSManaged var name: String
@NSManaged var category: Category?
// Derived from category.name
@NSManaged var categoryName: String?
// Derived from canonical:(name)
@NSManaged var searchName: String?
}
// Usage
article.name = "Core Data Best Practices"
try context.save()
// After save, derived attributes are updated
print(article.searchName) // "core data best practices"
print(article.categoryName) // "Swift"
```
## Transformables
Transformables allow storing custom types that aren't natively supported by Core Data.
### Creating a Value Transformer
```swift
import UIKit
@objc(ColorTransformer)
class ColorTransformer: ValueTransformer {
override class func transformedValueClass() -> AnyClass {
return NSData.self
}
override class func allowsReverseTransformation() -> Bool {
return true
}
override func transformedValue(_ value: Any?) -> Any? {
guard let color = value as? UIColor else { return nil }
do {
let data = try NSKeyedArchiver.archivedData(
withRootObject: color,
requiringSecureCoding: true
)
return data
} catch {
print("Failed to transform color: \(error)")
return nil
}
}
override func reverseTransformedValue(_ value: Any?) -> Any? {
guard let data = value as? Data else { return nil }
do {
let color = try NSKeyedUnarchiver.unarchivedObject(
ofClass: UIColor.self,
from: data
)
return color
} catch {
print("Failed to reverse transform color: \(error)")
return nil
}
}
}
```
### Registering the Transformer
```swift
// In your stack setup, before loading stores
ValueTransformer.setValueTransformer(
ColorTransformer(),
forName: NSValueTransformerName("ColorTransformer")
)
```
### Configuring in Data Model
1. Select the attribute
2. Set Type to "Transformable"
3. Set "Custom Class" to your type (e.g., `UIColor`)
4. Set "Transformer" to your transformer name (e.g., `ColorTransformer`)
### Using Transformable Attributes
```swift
class Article: NSManagedObject {
@NSManaged var color: UIColor?
}
// Usage
article.color = .systemBlue
try context.save()
// Retrieval
let color = article.color // UIColor
```
### NSSecureCoding Requirement
Modern Core Data requires secure coding:
```swift
// Make your custom type conform to NSSecureCoding
extension CustomType: NSSecureCoding {
static var supportsSecureCoding: Bool { return true }
func encode(with coder: NSCoder) {
// Encode properties
}
required init?(coder: NSCoder) {
// Decode properties
}
}
```
## Validation
Core Data provides built-in validation that runs before saving.
### Model-Level Validation
Set in Data Model Editor:
**String Validation:**
- Minimum Length
- Maximum Length
- Regular Expression
**Numeric Validation:**
- Minimum Value
- Maximum Value
**Example:**
```
Attribute: name
Type: String
Min Length: 3
Max Length: 100
```
### Code-Level Validation
Override validation methods in your `NSManagedObject` subclass:
```swift
class Article: NSManagedObject {
@NSManaged var name: String?
// Validate before insert
override func validateForInsert() throws {
try super.validateForInsert()
try validateName()
}
// Validate before update
override func validateForUpdate() throws {
try super.validateForUpdate()
try validateName()
}
// Validate before delete
override func validateForDelete() throws {
try super.validateForDelete()
// Example: Can't delete if has related objects
if let attachments = attachments, !attachments.isEmpty {
throw NSError(
domain: "ArticleValidation",
code: 1001,
userInfo: [NSLocalizedDescriptionKey: "Cannot delete article with attachments"]
)
}
}
// Custom validation
private func validateName() throws {
guard let name = name, !name.isEmpty else {
throw NSError(
domain: "ArticleValidation",
code: 1000,
userInfo: [NSLocalizedDescriptionKey: "Name cannot be empty"]
)
}
// Check for protected names
let protectedNames = ["Admin", "System", "Root"]
if protectedNames.contains(name) {
throw NSError(
domain: "ArticleValidation",
code: 1002,
userInfo: [NSLocalizedDescriptionKey: "'\(name)' is a protected name"]
)
}
}
}
```
### Property-Level Validation
```swift
class Article: NSManagedObject {
@NSManaged var name: String?
override func validateName(_ value: AutoreleasingUnsafeMutablePointer<AnyObject?>) throws {
guard let name = value.pointee as? String, !name.isEmpty else {
throw NSError(
domain: "ArticleValidation",
code: 1000,
userInfo: [NSLocalizedDescriptionKey: "Name cannot be empty"]
)
}
}
}
```
### Handling Validation Errors
```swift
do {
try context.save()
} catch let error as NSError {
if error.domain == NSCocoaErrorDomain {
switch error.code {
case NSValidationStringTooShortError:
print("String too short")
case NSValidationStringTooLongError:
print("String too long")
case NSManagedObjectValidationError:
print("Validation failed")
default:
print("Other error: \(error.localizedDescription)")
}
}
}
```
## Lifecycle Events
Override lifecycle methods to perform actions at specific points in an object's life.
### awakeFromInsert()
Called once when object is first inserted into context.
```swift
override func awakeFromInsert() {
super.awakeFromInsert()
// Set default values
setPrimitiveValue(Date(), forKey: #keyPath(Article.creationDate))
setPrimitiveValue(Date(), forKey: #keyPath(Article.lastModified))
setPrimitiveValue(0, forKey: #keyPath(Article.views))
}
```
**Use `setPrimitiveValue` to avoid:**
- KVO notifications
- Marking object as changed
- Infinite loops
### willSave()
Called before every save. Use for updating modification dates or cleaning up.
```swift
override func willSave() {
super.willSave()
// Update modification date
setPrimitiveValue(Date(), forKey: #keyPath(Article.lastModified))
// Delete local files if object is deleted
if isDeleted, let localResource = localResourceURL {
try? FileManager.default.removeItem(at: localResource)
}
}
```
**Caution:** Don't call `save()` inside `willSave()` - infinite loop!
### didSave()
Called after save completes.
```swift
override func didSave() {
super.didSave()
// Post notification, update cache, etc.
NotificationCenter.default.post(
name: .articleDidSave,
object: self
)
}
```
### prepareForDeletion()
Called when object is marked for deletion (before save).
```swift
override func prepareForDeletion() {
super.prepareForDeletion()
// Cancel ongoing operations
downloadTask?.cancel()
// Don't delete files here! Use willSave() instead
// (prepareForDeletion is called even if save is rolled back)
}
```
**Important:** Don't delete files in `prepareForDeletion()`. The deletion might be rolled back, leaving your data inconsistent.
### awakeFromFetch()
Called when object is fetched from store.
```swift
override func awakeFromFetch() {
super.awakeFromFetch()
// Initialize transient properties
setupObservers()
}
```
### Complete Lifecycle Example
```swift
class Article: NSManagedObject {
@NSManaged var name: String?
@NSManaged var creationDate: Date?
@NSManaged var lastModified: Date?
@NSManaged var localResourceURL: URL?
override func awakeFromInsert() {
super.awakeFromInsert()
// Set creation date once
setPrimitiveValue(Date(), forKey: #keyPath(Article.creationDate))
setPrimitiveValue(Date(), forKey: #keyPath(Article.lastModified))
}
override func willSave() {
super.willSave()
// Update modification date on every save
if !isDeleted && changedValues().keys.contains("name") {
setPrimitiveValue(Date(), forKey: #keyPath(Article.lastModified))
}
// Clean up files when deleted
if isDeleted, let url = localResourceURL {
try? FileManager.default.removeItem(at: url)
}
}
override func prepareForDeletion() {
super.prepareForDeletion()
// Cancel ongoing operations
// Don't delete files here!
}
}
```
## Common Pitfalls
### ❌ Not Setting Merge Policy with Constraints
```swift
// Constraint violation will crash
let category = Category(context: context)
category.name = "Duplicate"
try context.save() // CRASH!
```
### ❌ Manually Setting Derived Attributes
```swift
// Derived attributes are read-only
article.categoryName = "Swift" // Ignored!
```
### ❌ Using KVO Methods in Lifecycle Events
```swift
override func awakeFromInsert() {
super.awakeFromInsert()
// Triggers KVO, marks as changed
self.creationDate = Date()
// Use primitive values
setPrimitiveValue(Date(), forKey: #keyPath(Article.creationDate))
}
```
### ❌ Deleting Files in prepareForDeletion
```swift
override func prepareForDeletion() {
super.prepareForDeletion()
// Bad: Deletion might be rolled back
try? FileManager.default.removeItem(at: fileURL)
}
```
### ✅ Correct Approaches
```swift
// Set merge policy
viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
// Let derived attributes compute themselves
article.name = "New Name"
try context.save()
print(article.searchName) // Automatically updated
// Use primitive values in lifecycle events
setPrimitiveValue(Date(), forKey: #keyPath(Article.creationDate))
// Delete files in willSave when isDeleted
override func willSave() {
super.willSave()
if isDeleted {
try? FileManager.default.removeItem(at: fileURL)
}
}
```
## Summary
1. **Use constraints for uniqueness** - Requires NSMergeByPropertyStoreTrumpMergePolicy
2. **Use derived attributes** - Better performance than accessing relationships
3. **Use canonical: for search** - Case and diacritic insensitive
4. **Use transformables for custom types** - With NSSecureCoding
5. **Validate in code** - For complex business rules
6. **Use awakeFromInsert for defaults** - Called once on creation
7. **Use willSave for updates** - Called before every save
8. **Use setPrimitiveValue** - Avoid KVO in lifecycle events
9. **Delete files in willSave** - When isDeleted is true
10. **Don't save in willSave** - Causes infinite loop

View File

@@ -0,0 +1,300 @@
# Performance Optimization
Optimizing Core Data performance requires understanding where bottlenecks occur and applying targeted solutions.
## Profiling with Instruments
### Time Profiler
1. In Xcode: Product → Profile
2. Select Time Profiler
3. Record while using app
4. Find heaviest stack traces
**Look for:**
- Excessive faulting
- Slow fetch requests
- Save operations taking too long
### Allocations Instrument
1. Product → Profile
2. Select Allocations
3. Monitor memory growth
4. Identify retained objects
**Look for:**
- Unbounded memory growth
- Objects not being released
- Large allocations
## SQL Debug Logging
Enable SQL logging:
```
-com.apple.CoreData.SQLDebug 1
```
**Output:**
```sql
CoreData: sql: SELECT Z_PK, ZNAME FROM ZARTICLE WHERE ZVIEWS > ? LIMIT 20
CoreData: annotation: sql execution time: 0.0023s
```
**Analyze:**
- Query complexity
- Execution time
- Number of queries (N+1 problem)
## Common Performance Issues
### 1. N+1 Query Problem
**Problem:**
```swift
// Fetches articles
let articles = try context.fetch(Article.fetchRequest())
// Each access fires a fault (N queries)
for article in articles {
print(article.category?.name) // Fault!
}
```
**Solution:**
```swift
let fetchRequest = Article.fetchRequest()
fetchRequest.relationshipKeyPathsForPrefetching = ["category"]
let articles = try context.fetch(fetchRequest)
// No faults fired
for article in articles {
print(article.category?.name) // Already loaded
}
```
### 2. Fetching Too Much Data
**Problem:**
```swift
// Fetches all properties of all objects
let articles = try context.fetch(Article.fetchRequest())
let count = articles.count
```
**Solution:**
```swift
// Only counts, doesn't fetch objects
let count = try context.count(for: Article.fetchRequest())
```
### 3. Not Using Batch Sizes
**Problem:**
```swift
// Loads 10,000 objects into memory
let fetchRequest = Article.fetchRequest()
let articles = try context.fetch(fetchRequest)
```
**Solution:**
```swift
fetchRequest.fetchBatchSize = 20
// Only loads 20 at a time
```
### 4. Fetching Unnecessary Properties
**Problem:**
```swift
// Fetches all properties
let fetchRequest = Article.fetchRequest()
```
**Solution:**
```swift
fetchRequest.propertiesToFetch = ["name", "creationDate"]
// Only fetches needed properties
```
### 5. Saving Too Frequently
**Problem:**
```swift
for item in items {
item.processed = true
try? context.save() // Very slow!
}
```
**Solution:**
```swift
for item in items {
item.processed = true
}
try? context.save() // Save once
```
### 6. Not Resetting Context
**Problem:**
```swift
// Context accumulates objects
for i in 0..<10000 {
let article = Article(context: context)
// Memory grows unbounded
}
```
**Solution:**
```swift
for i in 0..<10000 {
let article = Article(context: context)
if i % 100 == 0 {
try? context.save()
context.reset() // Clear memory
}
}
```
## Memory Management
### Context Reset
```swift
context.reset()
```
**When to use:**
- After processing large batches
- When context accumulates many objects
- To free memory
**Caution:** Invalidates all fetched objects from this context.
### Refresh Objects
```swift
context.refresh(article, mergeChanges: false)
```
**When to use:**
- Discard in-memory changes
- Free memory for specific object
- Reload from database
### Turn Objects into Faults
```swift
context.refreshAllObjects()
```
**When to use:**
- Free memory across all objects
- After large operations
- When memory is constrained
## Fetch Request Optimization
### Checklist
```swift
let fetchRequest = Article.fetchRequest()
// Set batch size
fetchRequest.fetchBatchSize = 20
// Limit properties
fetchRequest.propertiesToFetch = ["name", "views"]
// Prefetch relationships
fetchRequest.relationshipKeyPathsForPrefetching = ["category"]
// Use predicate to filter
fetchRequest.predicate = NSPredicate(format: "views > %d", 100)
// Set fetch limit if applicable
fetchRequest.fetchLimit = 10
// Specify sort descriptors
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
```
## Batch Operations
For large-scale operations, use batch requests:
```swift
// Instead of:
for article in articles {
article.isRead = true
}
try context.save()
// Use:
let batchUpdate = NSBatchUpdateRequest(entityName: "Article")
batchUpdate.propertiesToUpdate = ["isRead": true]
try context.execute(batchUpdate)
```
**Benefits:**
- 10-20x faster
- Lower memory usage
- SQL-level operations
## Data Generators for Testing
Create reproducible test datasets:
```swift
class DataGenerator {
func generate(count: Int, in context: NSManagedObjectContext) {
for i in 0..<count {
let article = Article(context: context)
article.name = "Article \(i)"
if i % 100 == 0 {
try? context.save()
context.reset()
}
}
try? context.save()
}
}
// Usage
let generator = DataGenerator()
generator.generate(count: 10000, in: backgroundContext)
```
## Profiling Checklist
1. **Enable SQL debug** - See actual queries
2. **Profile with Time Profiler** - Find slow operations
3. **Profile with Allocations** - Find memory issues
4. **Test with realistic data** - Small datasets hide problems
5. **Monitor on device** - Simulator performance differs
6. **Test on older devices** - Performance varies
## Quick Wins
1. **Use `count(for:)` instead of fetching** - 100x faster
2. **Set `fetchBatchSize`** - Reduces memory
3. **Prefetch relationships** - Eliminates N+1 queries
4. **Use `propertiesToFetch`** - Reduces data transfer
5. **Reset context periodically** - Frees memory
6. **Use batch operations** - 10-20x faster for bulk changes
7. **Save conditionally** - Check `hasPersistentChanges`
8. **Use background contexts** - Keep UI responsive
## Summary
1. **Profile first** - Measure before optimizing
2. **Use Instruments** - Time Profiler and Allocations
3. **Enable SQL debug** - Understand query behavior
4. **Optimize fetch requests** - Batch size, properties, prefetching
5. **Use batch operations** - For large-scale changes
6. **Reset contexts** - Free memory periodically
7. **Test with real data** - Small datasets hide issues
8. **Monitor on devices** - Real-world performance matters

View File

@@ -0,0 +1,553 @@
# Persistent History Tracking
Persistent history tracking enables Core Data to track changes across contexts, app extensions, and batch operations. This is essential for keeping your UI synchronized and supporting multi-target apps.
## Why Persistent History Tracking?
**Without persistent history tracking:**
- Batch operations don't update UI
- App extensions can't notify main app of changes
- Multiple contexts don't stay synchronized
**With persistent history tracking:**
- All changes are recorded in a transaction log
- Changes can be merged into any context
- Works across app targets (main app, extensions, etc.)
## Enabling Persistent History Tracking
### In NSPersistentContainer
```swift
class PersistentContainer: NSPersistentContainer {
override init(name: String, managedObjectModel model: NSManagedObjectModel) {
super.init(name: name, managedObjectModel: model)
guard let description = persistentStoreDescriptions.first else {
fatalError("No store description")
}
// Enable persistent history tracking
description.setOption(true as NSNumber,
forKey: NSPersistentHistoryTrackingKey)
// Enable remote change notifications
description.setOption(true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
loadPersistentStores { description, error in
if let error = error {
fatalError("Failed to load store: \(error)")
}
}
}
}
```
### For App Groups (Extensions)
```swift
let storeURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.example.app"
)?.appendingPathComponent("Shared.sqlite")
let description = NSPersistentStoreDescription(url: storeURL!)
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
container.persistentStoreDescriptions = [description]
```
## The Four Components
Persistent history tracking typically involves four components:
1. **Observer** - Listens for remote change notifications
2. **Fetcher** - Retrieves relevant transactions
3. **Merger** - Merges transactions into view context
4. **Cleaner** - Removes old transactions
## 1. Observer: Listening for Changes
```swift
final class PersistentHistoryObserver {
private let coordinator: NSPersistentStoreCoordinator
private let historyContext: NSManagedObjectContext
private let merger: PersistentHistoryMerger
init(container: NSPersistentContainer, viewContext: NSManagedObjectContext) {
self.coordinator = container.persistentStoreCoordinator
self.historyContext = container.newBackgroundContext()
self.historyContext.name = "PersistentHistoryContext"
self.historyContext.transactionAuthor = "PersistentHistory"
self.merger = PersistentHistoryMerger(historyContext: historyContext, viewContext: viewContext)
NotificationCenter.default.addObserver(
self,
selector: #selector(processStoreRemoteChanges),
name: .NSPersistentStoreRemoteChange,
object: coordinator
)
}
@objc private func processStoreRemoteChanges(_ notification: Notification) {
merger.merge()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
```
## 2. Fetcher: Retrieving Transactions
```swift
class PersistentHistoryFetcher {
private let context: NSManagedObjectContext
private let lastToken: NSPersistentHistoryToken?
init(context: NSManagedObjectContext, lastToken: NSPersistentHistoryToken?) {
self.context = context
self.lastToken = lastToken
}
func fetch() throws -> [NSPersistentHistoryTransaction] {
let fetchRequest = createFetchRequest()
guard let historyResult = try context.execute(fetchRequest) as? NSPersistentHistoryResult,
let transactions = historyResult.result as? [NSPersistentHistoryTransaction] else {
return []
}
return transactions
}
private func createFetchRequest() -> NSPersistentHistoryChangeRequest {
let request: NSPersistentHistoryChangeRequest
if let token = lastToken {
request = NSPersistentHistoryChangeRequest.fetchHistory(after: token)
} else {
request = NSPersistentHistoryChangeRequest.fetchHistory(after: Date.distantPast)
}
// Filter out transactions from this app target
if let fetchRequest = request.fetchRequest {
fetchRequest.predicate = NSPredicate(
format: "author != %@",
"MainApp" // Your app's transaction author
)
}
return request
}
}
```
## 3. Merger: Applying Changes
```swift
final class PersistentHistoryMerger {
private let historyContext: NSManagedObjectContext
private let viewContext: NSManagedObjectContext
private var lastToken: NSPersistentHistoryToken?
init(historyContext: NSManagedObjectContext, viewContext: NSManagedObjectContext) {
self.historyContext = historyContext
self.viewContext = viewContext
self.lastToken = loadLastToken()
}
func merge() {
historyContext.perform {
do {
let fetcher = PersistentHistoryFetcher(
context: self.historyContext,
lastToken: self.lastToken
)
let transactions = try fetcher.fetch()
guard !transactions.isEmpty else { return }
self.viewContext.perform {
self.mergeTransactions(transactions)
}
if let newToken = transactions.last?.token {
self.lastToken = newToken
self.saveLastToken(newToken)
}
} catch {
print("Failed to merge history: \(error)")
}
}
}
private func mergeTransactions(_ transactions: [NSPersistentHistoryTransaction]) {
for transaction in transactions {
guard let userInfo = transaction.objectIDNotification().userInfo else { continue }
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: userInfo, into: [viewContext])
}
}
private func loadLastToken() -> NSPersistentHistoryToken? {
guard let data = UserDefaults.standard.data(forKey: "lastHistoryToken") else {
return nil
}
return try? NSKeyedUnarchiver.unarchivedObject(
ofClass: NSPersistentHistoryToken.self,
from: data
)
}
private func saveLastToken(_ token: NSPersistentHistoryToken) {
if let data = try? NSKeyedArchiver.archivedData(
withRootObject: token,
requiringSecureCoding: true
) {
UserDefaults.standard.set(data, forKey: "lastHistoryToken")
}
}
}
```
## 4. Cleaner: Removing Old Transactions
```swift
class PersistentHistoryCleaner {
private let context: NSManagedObjectContext
private let targets: [AppTarget]
enum AppTarget {
case mainApp
case shareExtension
case widgetExtension
var lastTokenKey: String {
switch self {
case .mainApp: return "mainApp.lastHistoryToken"
case .shareExtension: return "shareExtension.lastHistoryToken"
case .widgetExtension: return "widgetExtension.lastHistoryToken"
}
}
}
init(context: NSManagedObjectContext, targets: [AppTarget]) {
self.context = context
self.targets = targets
}
func clean() {
context.perform {
// Find the oldest token across all targets
guard let oldestToken = self.findOldestToken() else { return }
// Delete history before that token
let deleteRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: oldestToken)
do {
try self.context.execute(deleteRequest)
} catch {
print("Failed to clean history: \(error)")
}
}
}
private func findOldestToken() -> NSPersistentHistoryToken? {
var oldestDate: Date?
var oldestToken: NSPersistentHistoryToken?
for target in targets {
guard let token = loadToken(for: target) else { continue }
// Get timestamp from token (requires fetching transaction)
let historyRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: token)
historyRequest.fetchRequest?.fetchLimit = 1
guard let result = try? context.execute(historyRequest) as? NSPersistentHistoryResult,
let transactions = result.result as? [NSPersistentHistoryTransaction],
let transaction = transactions.first else {
continue
}
let date = transaction.timestamp
if oldestDate == nil || date < oldestDate! {
oldestDate = date
oldestToken = token
}
}
return oldestToken
}
private func loadToken(for target: AppTarget) -> NSPersistentHistoryToken? {
guard let data = UserDefaults.standard.data(forKey: target.lastTokenKey) else {
return nil
}
return try? NSKeyedUnarchiver.unarchivedObject(
ofClass: NSPersistentHistoryToken.self,
from: data
)
}
}
```
## Complete Integration Example
```swift
class CoreDataStack {
static let shared = CoreDataStack()
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "Model")
// Configure store
guard let description = container.persistentStoreDescriptions.first else {
fatalError("No store description")
}
// Enable persistent history tracking
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Failed to load store: \(error)")
}
self.setupHistoryTracking(container: container)
}
// Configure view context
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.name = "ViewContext"
container.viewContext.transactionAuthor = "MainApp"
return container
}()
private var historyObserver: PersistentHistoryObserver?
private init() {}
private func setupHistoryTracking(container: NSPersistentContainer) {
historyObserver = PersistentHistoryObserver(container: container, viewContext: container.viewContext)
cleanHistoryPeriodically(container: container)
}
private func cleanHistoryPeriodically(container: NSPersistentContainer) {
Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { _ in
let context = container.newBackgroundContext()
let cleaner = PersistentHistoryCleaner(
context: context,
targets: [.mainApp, .shareExtension]
)
cleaner.clean()
}
}
}
```
## Transaction Authors
Set unique transaction authors for each app target:
```swift
// Main app
viewContext.transactionAuthor = "MainApp"
// Share extension
viewContext.transactionAuthor = "ShareExtension"
// Widget extension
viewContext.transactionAuthor = "WidgetExtension"
```
**Why this matters:**
- Filter out your own transactions (avoid redundant merges)
- Identify which target made changes
- Debug multi-target issues
## Filtering Transactions
### By Author
```swift
let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)
if let request = fetchRequest.fetchRequest {
request.predicate = NSPredicate(format: "author != %@", "MainApp")
}
```
### By Date
```swift
let cutoffDate = Calendar.current.date(byAdding: .day, value: -7, to: Date())!
let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: cutoffDate)
```
### By Entity
```swift
let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)
if let request = fetchRequest.fetchRequest {
request.predicate = NSPredicate(format: "ANY changes.changedObjectID.entity.name == %@", "Article")
}
```
## Batch Operations Integration
Persistent history tracking is **required** for batch operations to update the UI:
```swift
// 1. Enable persistent history tracking
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
// 2. Perform batch operation
let context = container.newBackgroundContext()
context.perform {
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { object in
// Insert logic
return false
}
try? context.execute(batchInsert)
}
// 3. UI updates automatically via persistent history tracking
// The observer detects the change and merges it into the view context
```
## Testing Persistent History
```swift
func testPersistentHistory() throws {
// Enable persistent history
let description = container.persistentStoreDescriptions.first!
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
// Create object in background
let backgroundContext = container.newBackgroundContext()
backgroundContext.transactionAuthor = "Test"
let expectation = XCTestExpectation(description: "Save")
backgroundContext.perform {
let article = Article(context: backgroundContext)
article.name = "Test"
try? backgroundContext.save()
expectation.fulfill()
}
wait(for: [expectation], timeout: 5.0)
// Fetch history
let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: Date.distantPast)
let result = try container.viewContext.execute(fetchRequest) as? NSPersistentHistoryResult
let transactions = result?.result as? [NSPersistentHistoryTransaction]
XCTAssertNotNil(transactions)
XCTAssertFalse(transactions!.isEmpty)
}
```
## Common Pitfalls
### ❌ Not Enabling Remote Change Notifications
```swift
// Only this isn't enough
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
// Need both!
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
```
### ❌ Not Filtering Own Transactions
```swift
// Merges own transactions (redundant)
let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)
```
### ❌ Not Cleaning Old Transactions
```swift
// History grows unbounded, wastes space
// Always implement cleaning!
```
### ❌ Not Setting Transaction Authors
```swift
// Can't filter transactions by source
context.transactionAuthor = nil // Bad!
```
### ✅ Correct Approach
```swift
// 1. Enable both options
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
// 2. Set transaction author
context.transactionAuthor = "MainApp"
// 3. Filter own transactions
fetchRequest.predicate = NSPredicate(format: "author != %@", "MainApp")
// 4. Clean periodically
let cleaner = PersistentHistoryCleaner(context: context, targets: [.mainApp, .shareExtension])
cleaner.clean()
```
## Performance Considerations
### Clean History Regularly
```swift
// Clean daily
Timer.scheduledTimer(withTimeInterval: 86400, repeats: true) { _ in
cleaner.clean()
}
// Or on app launch
func applicationDidFinishLaunching() {
cleaner.clean()
}
```
### Limit Fetch Range
```swift
// Don't fetch all history
let sevenDaysAgo = Calendar.current.date(byAdding: .day, value: -7, to: Date())!
let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: sevenDaysAgo)
```
### Batch Merge Changes
```swift
// Merge multiple transactions at once
let transactions = try fetcher.fetch()
for transaction in transactions {
let userInfo = transaction.objectIDNotification().userInfo
NSManagedObjectContext.mergeChanges(
fromRemoteContextSave: userInfo!,
into: [viewContext]
)
}
```
## Summary
1. **Enable persistent history tracking** - Required for batch operations and multi-target apps
2. **Enable remote change notifications** - Required for cross-context updates
3. **Set transaction authors** - Identify change sources
4. **Filter own transactions** - Avoid redundant merges
5. **Implement all four components** - Observer, Fetcher, Merger, Cleaner
6. **Clean history regularly** - Prevent unbounded growth
7. **Use with batch operations** - Essential for UI updates
8. **Test thoroughly** - Verify history tracking works across targets

View File

@@ -0,0 +1,60 @@
# Project Audit (Core Data)
Use this checklist to quickly discover how a project uses Core Data and which constraints apply (platform availability, CloudKit, history tracking, etc.).
## Determine platform constraints
- Find the deployment target (iOS/macOS version). Many recommendations depend on this (e.g. staged migration and composite attributes require iOS 17+/macOS 14+).
- Note whether the project is Swift 6 / strict concurrency enabled (Sendable and isolation warnings change the advice).
## Inspect the data model
- Open the model XML (`*.xcdatamodeld/*/contents`) and check:
- entities, attributes, relationships, constraints
- versioning setup (multiple model versions)
- renaming identifiers (for lightweight migration)
- composite attributes (iOS 17+)
## Identify stack setup
Search for:
- `NSPersistentContainer` vs `NSPersistentCloudKitContainer`
- `loadPersistentStores` configuration
- `persistentStoreDescriptions` (migration options, history tracking, CloudKit options)
- `viewContext` configuration (merge policy, `automaticallyMergesChangesFromParent`, query generations)
- background context creation (`newBackgroundContext`, `performBackgroundTask`)
Then consult:
- `stack-setup.md` for recommended defaults and merge policies
- `cloudkit-integration.md` if CloudKit is enabled
## Check for persistent history tracking (required for some flows)
Search for:
- `NSPersistentHistoryTrackingKey`
- `NSPersistentStoreRemoteChangeNotificationPostOptionKey`
- remote change notifications and history processing/merging
Then consult:
- `persistent-history.md` for the Observer/Fetcher/Merger/Cleaner pattern
## Spot risky concurrency patterns
Search for:
- cross-thread access to managed objects (look for passing `NSManagedObject` into async tasks/closures)
- `performAndWait` usage (risk of deadlocks / UI blocking)
- `@unchecked Sendable` applied to Core Data types (usually hides a real problem)
Then consult:
- `threading.md` and `concurrency.md`
## Useful debugging flags (for repro builds only)
- `-com.apple.CoreData.ConcurrencyDebug 1` (threading violations)
- `-com.apple.CoreData.SQLDebug 1` (SQL logging)

View File

@@ -0,0 +1,574 @@
# Saving in Core Data
Saving data efficiently is crucial for app performance and user experience. This guide covers best practices for when, how, and where to save your Core Data changes.
## The Problem with Always Saving
Calling `save()` unconditionally has performance costs:
```swift
// Bad: Always saving, even when nothing changed
func updateUI() {
article.lastViewed = Date()
try? context.save() // Expensive even if nothing changed!
}
```
**Problems:**
- Writes to disk even when no changes exist
- Triggers merge notifications unnecessarily
- Wastes CPU and battery
- Slows down your app
## Conditional Saving with hasChanges
The first improvement is checking `hasChanges`:
```swift
// Better: Only save if there are changes
if context.hasChanges {
try context.save()
}
```
**Benefits:**
- Avoids unnecessary disk writes
- Faster performance
- Still simple to use
**Limitation:**
- `hasChanges` returns `true` for transient properties too
- Transient changes don't need to be persisted
## Best Practice: hasPersistentChanges
Check for **persistent** changes only, excluding transient properties:
```swift
extension NSManagedObjectContext {
var hasPersistentChanges: Bool {
return !insertedObjects.isEmpty ||
!deletedObjects.isEmpty ||
updatedObjects.contains(where: { $0.hasPersistentChangedValues })
}
func saveIfNeeded() throws {
guard hasPersistentChanges else { return }
try save()
}
}
// Usage
try context.saveIfNeeded()
```
**Why this is better:**
- Excludes transient property changes
- Only saves when data actually needs persisting
- Most efficient approach
### Understanding hasPersistentChangedValues
```swift
extension NSManagedObject {
var hasPersistentChangedValues: Bool {
return !changedValues().isEmpty
}
}
```
This checks if the object has **any** changed values. For more granular control:
```swift
extension NSManagedObject {
var hasPersistentChangedValues: Bool {
let changedKeys = Set(changedValues().keys)
let persistentKeys = Set(entity.attributesByName.keys)
.union(entity.relationshipsByName.keys)
.subtracting(entity.transientAttributeNames)
return !changedKeys.intersection(persistentKeys).isEmpty
}
}
extension NSEntityDescription {
var transientAttributeNames: Set<String> {
return Set(attributesByName.filter { $0.value.isTransient }.map { $0.key })
}
}
```
## When to Save
### Save on App Lifecycle Events
```swift
// AppDelegate or SceneDelegate
func applicationWillTerminate(_ application: UIApplication) {
try? CoreDataStack.shared.viewContext.saveIfNeeded()
}
func sceneDidEnterBackground(_ scene: UIScene) {
try? CoreDataStack.shared.viewContext.saveIfNeeded()
}
```
### Save After User Actions
```swift
// After user completes an action
@IBAction func saveButtonTapped(_ sender: UIButton) {
article.name = nameTextField.text
article.content = contentTextView.text
do {
try context.saveIfNeeded()
dismiss(animated: true)
} catch {
// Handle error
showError(error)
}
}
```
### Save Periodically for Long-Running Operations
```swift
func importLargeDataset() {
let context = container.newBackgroundContext()
context.perform {
for (index, data) in largeDataset.enumerated() {
let article = Article(context: context)
article.name = data.name
// Save every 100 objects
if index % 100 == 0 {
try? context.saveIfNeeded()
}
}
// Final save
try? context.saveIfNeeded()
}
}
```
### Don't Save Too Frequently
```swift
// Bad: Saving on every keystroke
func textFieldDidChange(_ textField: UITextField) {
article.name = textField.text
try? context.save() // Too frequent!
}
// Better: Save when editing ends
func textFieldDidEndEditing(_ textField: UITextField) {
article.name = textField.text
try? context.saveIfNeeded()
}
// Best: Use debouncing for auto-save
private var saveWorkItem: DispatchWorkItem?
func textFieldDidChange(_ textField: UITextField) {
article.name = textField.text
// Cancel previous save
saveWorkItem?.cancel()
// Schedule new save after 2 seconds of inactivity
let workItem = DispatchWorkItem { [weak self] in
try? self?.context.saveIfNeeded()
}
saveWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: workItem)
}
```
## Error Handling
### Basic Error Handling
```swift
do {
try context.save()
} catch {
print("Failed to save: \(error)")
}
```
### Detailed Error Handling
```swift
do {
try context.save()
} catch let error as NSError {
print("Failed to save context: \(error)")
print("User info: \(error.userInfo)")
// Check for specific errors
if error.domain == NSCocoaErrorDomain {
switch error.code {
case NSValidationStringTooShortError:
print("String too short")
case NSValidationStringTooLongError:
print("String too long")
case NSManagedObjectValidationError:
print("Validation error")
case NSManagedObjectConstraintValidationError:
print("Constraint violation")
default:
print("Other error: \(error.code)")
}
}
}
```
### User-Friendly Error Messages
```swift
extension NSError {
var userFriendlyMessage: String {
guard domain == NSCocoaErrorDomain else {
return localizedDescription
}
switch code {
case NSValidationStringTooShortError:
return "The text is too short. Please enter at least 3 characters."
case NSValidationStringTooLongError:
return "The text is too long. Please keep it under 100 characters."
case NSManagedObjectConstraintValidationError:
return "This item already exists. Please use a different name."
case NSManagedObjectValidationError:
return "Please check your input and try again."
default:
return "Failed to save: \(localizedDescription)"
}
}
}
// Usage
do {
try context.save()
} catch let error as NSError {
showAlert(message: error.userFriendlyMessage)
}
```
## Saving in Different Contexts
### View Context (Main Thread)
```swift
// Always on main thread
let context = container.viewContext
// Simple save
try? context.saveIfNeeded()
// With error handling
do {
try context.saveIfNeeded()
} catch {
print("Failed to save: \(error)")
}
```
### Background Context
```swift
let context = container.newBackgroundContext()
context.perform {
// Make changes
let article = Article(context: context)
article.name = "New Article"
// Save within perform block
do {
try context.saveIfNeeded()
} catch {
print("Failed to save: \(error)")
}
}
```
### Nested Contexts (Advanced)
```swift
// Parent context (view context)
let parentContext = container.viewContext
// Child context for editing
let childContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
childContext.parent = parentContext
// Make changes in child
let article = childContext.object(with: articleID) as! Article
article.name = "Updated"
// Save child (pushes to parent, not to disk)
try? childContext.save()
// Save parent to persist to disk
try? parentContext.save()
```
**Use nested contexts for:**
- Cancellable editing (discard child without saving parent)
- Temporary changes
- Complex forms
## Saving with Validation
Core Data validates objects before saving. Handle validation errors appropriately:
```swift
do {
try context.save()
} catch let error as NSError {
if error.code == NSValidationMultipleErrorsError {
// Multiple validation errors
if let errors = error.userInfo[NSDetailedErrorsKey] as? [NSError] {
for validationError in errors {
print("Validation error: \(validationError.localizedDescription)")
// Get the object that failed validation
if let object = validationError.userInfo[NSValidationObjectErrorKey] as? NSManagedObject {
print("Failed object: \(object)")
}
// Get the property that failed
if let key = validationError.userInfo[NSValidationKeyErrorKey] as? String {
print("Failed property: \(key)")
}
}
}
}
}
```
## Optimizing Save Performance
### Batch Saves During Import
```swift
func importArticles(_ articles: [ArticleData]) {
let context = container.newBackgroundContext()
context.perform {
for (index, data) in articles.enumerated() {
let article = Article(context: context)
article.name = data.name
article.content = data.content
// Save every 100 objects to avoid memory buildup
if index % 100 == 0 && context.hasChanges {
try? context.save()
context.reset() // Clear memory
}
}
// Final save
try? context.save()
}
}
```
### Avoid Saving in Loops
```swift
// Bad: Saving inside loop
for data in dataArray {
let article = Article(context: context)
article.name = data.name
try? context.save() // Very slow!
}
// Good: Save once after loop
for data in dataArray {
let article = Article(context: context)
article.name = data.name
}
try? context.save() // Much faster!
```
### Use Batch Operations for Bulk Changes
For large-scale operations, use batch requests instead of saving individual objects:
```swift
// Instead of:
for article in articles {
article.isRead = true
}
try? context.save()
// Use batch update:
let batchUpdate = NSBatchUpdateRequest(entityName: "Article")
batchUpdate.predicate = NSPredicate(format: "isRead == NO")
batchUpdate.propertiesToUpdate = ["isRead": true]
try? context.execute(batchUpdate)
```
See `batch-operations.md` for more details.
## Checking for Unsaved Changes
### Before Dismissing a View
```swift
func dismiss() {
if context.hasChanges {
let alert = UIAlertController(
title: "Unsaved Changes",
message: "Do you want to save your changes?",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Save", style: .default) { _ in
try? self.context.save()
self.dismissView()
})
alert.addAction(UIAlertAction(title: "Discard", style: .destructive) { _ in
self.context.rollback()
self.dismissView()
})
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
present(alert, animated: true)
} else {
dismissView()
}
}
```
### Rollback Unsaved Changes
```swift
// Discard all unsaved changes
context.rollback()
// Refresh a specific object to discard its changes
context.refresh(article, mergeChanges: false)
```
## Save Notifications
Observe save notifications to respond to changes:
```swift
NotificationCenter.default.addObserver(
self,
selector: #selector(contextDidSave),
name: .NSManagedObjectContextDidSave,
object: context
)
@objc func contextDidSave(_ notification: Notification) {
guard let userInfo = notification.userInfo else { return }
if let inserts = userInfo[NSInsertedObjectsKey] as? Set<NSManagedObject> {
print("Inserted: \(inserts.count) objects")
}
if let updates = userInfo[NSUpdatedObjectsKey] as? Set<NSManagedObject> {
print("Updated: \(updates.count) objects")
}
if let deletes = userInfo[NSDeletedObjectsKey] as? Set<NSManagedObject> {
print("Deleted: \(deletes.count) objects")
}
}
```
## Testing Saves
### In Unit Tests
```swift
func testSaveArticle() throws {
let context = testContainer.viewContext
let article = Article(context: context)
article.name = "Test Article"
XCTAssertTrue(context.hasChanges)
try context.save()
XCTAssertFalse(context.hasChanges)
// Verify saved
let fetchRequest = Article.fetchRequest()
let results = try context.fetch(fetchRequest)
XCTAssertEqual(results.count, 1)
XCTAssertEqual(results.first?.name, "Test Article")
}
```
## Common Pitfalls
### ❌ Not Checking for Changes
```swift
// Wastes resources
try? context.save()
```
### ❌ Saving Too Frequently
```swift
// In a loop - very slow
for item in items {
item.processed = true
try? context.save()
}
```
### ❌ Ignoring Errors
```swift
// Silent failures
try? context.save()
```
### ❌ Saving on Wrong Thread
```swift
// Crash! Background context on main thread
let context = container.newBackgroundContext()
try? context.save() // Not in perform block!
```
### ✅ Correct Approach
```swift
// Check for changes
guard context.hasPersistentChanges else { return }
// Handle errors
do {
try context.save()
} catch {
print("Save failed: \(error)")
// Handle appropriately
}
// Use correct thread
context.perform {
try? context.save()
}
```
## Summary
1. **Use `saveIfNeeded()` with `hasPersistentChanges`** - Most efficient approach
2. **Save at appropriate times** - App lifecycle events, after user actions, periodically
3. **Don't save too frequently** - Debounce auto-saves, avoid saving in loops
4. **Handle errors properly** - Don't ignore save failures
5. **Use correct context type** - View context for UI, background for heavy work
6. **Always use `perform` with background contexts** - Thread safety
7. **Consider batch operations** - For large-scale updates
8. **Test your saves** - Verify data persists correctly

View File

@@ -0,0 +1,625 @@
# Core Data Stack Setup
Setting up your Core Data stack correctly is foundational to a well-architected app. This guide covers best practices for configuring `NSPersistentContainer`, managing contexts, and establishing patterns that scale.
## Custom NSPersistentContainer
Create a custom subclass instead of configuring everything in `AppDelegate`. This keeps your stack configuration organized and testable.
```swift
import CoreData
class PersistentContainer: NSPersistentContainer {
static let shared = PersistentContainer(name: "DataModel")
private override init(name: String, managedObjectModel model: NSManagedObjectModel) {
super.init(name: name, managedObjectModel: model)
configure()
}
convenience init(name: String) {
guard let modelURL = Bundle.main.url(forResource: name, withExtension: "momd"),
let model = NSManagedObjectModel(contentsOf: modelURL) else {
fatalError("Failed to load data model")
}
self.init(name: name, managedObjectModel: model)
}
private func configure() {
// Set merge policy for constraint handling
viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
// Enable automatic merging from parent
viewContext.automaticallyMergesChangesFromParent = true
// Name the view context for debugging
viewContext.name = "ViewContext"
// Configure store options before loading
configureStoreDescription()
// Load persistent stores
loadPersistentStores { description, error in
if let error = error {
// Handle error appropriately
fatalError("Failed to load persistent store: \(error)")
}
}
}
private func configureStoreDescription() {
guard let description = persistentStoreDescriptions.first else { return }
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
}
}
```
## Singleton Pattern vs Dependency Injection
### Singleton Pattern (Recommended for Most Apps)
```swift
class PersistentContainer: NSPersistentContainer {
static let shared = PersistentContainer(name: "DataModel")
// Prevent external initialization
private override init(name: String, managedObjectModel model: NSManagedObjectModel) {
super.init(name: name, managedObjectModel: model)
}
}
// Usage
let context = PersistentContainer.shared.viewContext
```
**Pros:**
- Simple, consistent access across the app
- No need to pass container through the app
- Works well with SwiftUI environment
**Cons:**
- Harder to test with different configurations
- Global state
### Dependency Injection (Better for Testing)
```swift
class DataController {
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "DataModel")
if inMemory {
container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Failed to load store: \(error)")
}
}
}
var viewContext: NSManagedObjectContext {
container.viewContext
}
}
// Usage
let dataController = DataController()
let context = dataController.viewContext
// Testing
let testController = DataController(inMemory: true)
```
**Pros:**
- Easier to test with in-memory stores
- More flexible configuration
- Better for unit testing
**Cons:**
- Must pass controller through the app
- More boilerplate
## Merge Policies
Merge policies determine how Core Data resolves conflicts when saving. Choose based on your app's needs.
### NSMergeByPropertyStoreTrumpMergePolicy (Recommended)
Store values win over in-memory values. **Required for constraints to work.**
```swift
viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
```
**Use when:**
- Using unique constraints
- Store data should take precedence
- Multiple contexts might modify the same objects
### NSMergeByPropertyObjectTrumpMergePolicy
In-memory values win over store values.
```swift
viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
```
**Use when:**
- User edits should always win
- In-memory changes are more important
### NSOverwriteMergePolicy
In-memory object completely replaces store object.
```swift
viewContext.mergePolicy = NSOverwriteMergePolicy
```
**Use when:**
- You want complete replacement
- Conflicts should never occur
### NSRollbackMergePolicy
Discard in-memory changes, keep store values.
```swift
viewContext.mergePolicy = NSRollbackMergePolicy
```
**Use when:**
- Store is source of truth
- In-memory changes should be discarded on conflict
### NSErrorMergePolicy (Default)
Throws an error on conflict. You must handle manually.
```swift
viewContext.mergePolicy = NSErrorMergePolicy
do {
try context.save()
} catch let error as NSError {
if error.code == NSManagedObjectMergeError {
// Handle merge conflict
}
}
```
**Use when:**
- You need custom conflict resolution
- Conflicts should be explicitly handled
## Context Configuration
### View Context
The view context runs on the main thread and should be used for all UI operations.
```swift
let viewContext = container.viewContext
viewContext.name = "ViewContext"
viewContext.automaticallyMergesChangesFromParent = true
viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
```
**Best Practices:**
- Only use for UI-related fetches and updates
- Keep operations lightweight
- Enable automatic merging from parent
- Set a descriptive name for debugging
### Background Context
Background contexts run on private queues and should be used for heavy work.
```swift
override func newBackgroundContext() -> NSManagedObjectContext {
let context = super.newBackgroundContext()
context.name = "BackgroundContext"
context.transactionAuthor = "BackgroundAuthor"
context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
context.automaticallyMergesChangesFromParent = true
return context
}
// Usage
let context = container.newBackgroundContext()
context.perform {
// Heavy work here
try? context.save()
}
```
**Best Practices:**
- Use for imports, exports, batch operations
- Always wrap work in `perform { }`
- Set transaction author for persistent history tracking
- Enable automatic merging
### Context Naming and Transaction Authors
Naming contexts helps with debugging and persistent history tracking.
```swift
context.name = "ImportContext"
context.transactionAuthor = "ImportAuthor"
```
**Benefits:**
- Identify contexts in Instruments
- Filter persistent history transactions
- Debug threading issues more easily
- Track which part of app made changes
**Example with App Extensions:**
```swift
// Main app
mainContext.transactionAuthor = "MainApp"
// Share extension
shareContext.transactionAuthor = "ShareExtension"
// Filter transactions by author
let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)
if let historyFetch = fetchRequest as? NSPersistentHistoryChangeRequest {
historyFetch.fetchRequest?.predicate = NSPredicate(
format: "author != %@", "MainApp"
)
}
```
## Understanding Store Loading Behavior
The `loadPersistentStores` method is **always asynchronous** - it uses a completion handler that's called when loading finishes. There is no synchronous version of this API.
### Standard Pattern (Recommended)
```swift
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Failed to load store: \(error)")
}
}
// Code here executes immediately, before stores finish loading
// However, in typical setup() methods, the app waits for completion
```
**Characteristics:**
- Completion handler called asynchronously when loading finishes
- Code after `loadPersistentStores` executes immediately
- App typically waits for stores to load before showing UI
- Most common and recommended pattern
**When to use:**
- Standard app initialization
- When you control the setup flow
- When you can ensure UI doesn't appear until stores are ready
### Modern async/await Pattern (iOS 15+)
```swift
extension NSPersistentContainer {
func loadPersistentStores() async throws {
try await withCheckedThrowingContinuation { continuation in
self.loadPersistentStores { description, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: ())
}
}
}
}
}
// Usage in async context
func setupCoreData() async throws {
let container = NSPersistentContainer(name: "Model")
try await container.loadPersistentStores()
// Stores are guaranteed loaded here
}
```
**Benefits:**
- Cleaner async/await syntax
- Better error handling with try/catch
- Easier to compose with other async operations
- Explicit about async nature
**When to use:**
- iOS 15+ deployment target
- Modern Swift concurrency codebase
- When composing with other async operations
### Deferred Loading Pattern (Advanced)
For rare cases where you need the app to start before stores are loaded:
```swift
class CoreDataStack {
let container: NSPersistentContainer
private(set) var isStoreLoaded = false
init() {
container = NSPersistentContainer(name: "Model")
loadStoresInBackground()
}
private func loadStoresInBackground() {
container.loadPersistentStores { [weak self] description, error in
if let error = error {
print("Failed to load store: \(error)")
return
}
self?.isStoreLoaded = true
NotificationCenter.default.post(name: .storeDidLoad, object: nil)
}
}
func waitForStoreLoad() async {
guard !isStoreLoaded else { return }
await withCheckedContinuation { continuation in
let observer = NotificationCenter.default.addObserver(
forName: .storeDidLoad,
object: nil,
queue: nil
) { _ in
continuation.resume()
}
// Check again in case it loaded while setting up observer
if self.isStoreLoaded {
NotificationCenter.default.removeObserver(observer)
continuation.resume()
}
}
}
}
```
**Cautions:**
- Must handle "not ready" state throughout app
- More complex error handling
- Potential race conditions if not careful
- Only use if you have a specific reason
**When to use:**
- Very large databases where loading takes significant time
- Apps that can show UI before data is available
- Background initialization scenarios
### Recommendation
**Use the standard pattern** with completion handler for most apps. The loading time is typically negligible (milliseconds), and waiting for stores to load before showing UI provides predictable behavior and avoids race conditions.
**Use async/await** if you're on iOS 15+ and want modern Swift concurrency patterns.
**Avoid deferred loading** unless you have a specific, measured need for it. The complexity and potential for bugs usually outweigh any perceived benefits.
## Store Configuration Options
### In-Memory Store (Testing)
```swift
let description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
container.persistentStoreDescriptions = [description]
```
**Use for:**
- Unit tests
- Temporary data
- Prototyping
### SQLite Store (Production)
```swift
let description = NSPersistentStoreDescription(url: storeURL)
description.type = NSSQLiteStoreType
container.persistentStoreDescriptions = [description]
```
**Use for:**
- Production apps
- Persistent data
- Most common use case
### Store Location
```swift
// Default location
let storeURL = NSPersistentContainer.defaultDirectoryURL()
.appendingPathComponent("Model.sqlite")
// Custom location
let storeURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("MyApp.sqlite")
// App Group (for extensions)
let storeURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.example.app"
)?.appendingPathComponent("Shared.sqlite")
```
## Complete Example
Here's a production-ready stack setup:
```swift
import CoreData
final class CoreDataStack {
static let shared = CoreDataStack()
private let containerName = "DataModel"
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: containerName)
// Configure store description
guard let description = container.persistentStoreDescriptions.first else {
fatalError("Failed to retrieve store description")
}
// Enable persistent history tracking
description.setOption(true as NSNumber,
forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
// Load stores
container.loadPersistentStores { storeDescription, error in
if let error = error as NSError? {
// Handle error appropriately in production
fatalError("Unresolved error \(error), \(error.userInfo)")
}
}
// Configure view context
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
container.viewContext.name = "ViewContext"
return container
}()
var viewContext: NSManagedObjectContext {
persistentContainer.viewContext
}
func newBackgroundContext() -> NSManagedObjectContext {
let context = persistentContainer.newBackgroundContext()
context.name = "BackgroundContext"
context.transactionAuthor = "BackgroundAuthor"
context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
context.automaticallyMergesChangesFromParent = true
return context
}
func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) {
persistentContainer.performBackgroundTask { context in
context.name = "BackgroundTask"
context.transactionAuthor = "BackgroundTaskAuthor"
context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
block(context)
}
}
private init() {}
}
// Usage
let context = CoreDataStack.shared.viewContext
// Background work
CoreDataStack.shared.performBackgroundTask { context in
// Heavy work here
try? context.save()
}
```
## SwiftUI Integration
### Environment Object Pattern
```swift
import SwiftUI
@main
struct MyApp: App {
let persistenceController = PersistentContainer.shared
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, persistenceController.viewContext)
}
}
}
// Usage in views
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Article.name, ascending: true)],
animation: .default)
private var articles: FetchedResults<Article>
var body: some View {
List(articles) { article in
Text(article.name ?? "")
}
}
}
```
## Common Pitfalls
### ❌ Configuring in AppDelegate
```swift
// Don't do this - hard to test and maintain
class AppDelegate: UIApplicationDelegate {
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "Model")
// Lots of configuration code here...
return container
}()
}
```
### ❌ Not Setting Merge Policy with Constraints
```swift
// This will crash when constraints are violated
let entity = MyEntity(context: context)
entity.uniqueField = "duplicate" // Constraint violation
try context.save() // CRASH!
```
### ❌ Not Naming Contexts
```swift
// Hard to debug which context has issues
let context = container.newBackgroundContext()
// No name, no transaction author
```
### ✅ Correct Approach
```swift
class PersistentContainer: NSPersistentContainer {
static let shared = PersistentContainer(name: "Model")
override func newBackgroundContext() -> NSManagedObjectContext {
let context = super.newBackgroundContext()
context.name = "BackgroundContext"
context.transactionAuthor = "BackgroundAuthor"
context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
context.automaticallyMergesChangesFromParent = true
return context
}
}
```
## Summary
1. **Create a custom NSPersistentContainer subclass** for organized configuration
2. **Use singleton pattern for simplicity** or dependency injection for testability
3. **Set merge policy** to NSMergeByPropertyStoreTrumpMergePolicy (required for constraints)
4. **Name contexts and set transaction authors** for debugging and history tracking
5. **Enable automaticallyMergesChangesFromParent** on all contexts
6. **Load stores using the completion handler (or async bridge)** and gate access until loading completes
7. **Configure persistent history tracking** if using batch operations or app extensions

View File

@@ -0,0 +1,300 @@
# Testing Core Data
Testing Core Data requires special setup to avoid conflicts and ensure fast, reliable tests.
## In-Memory Stores
Use in-memory stores for fast, isolated tests:
```swift
class CoreDataTestCase: XCTestCase {
var container: NSPersistentContainer!
var context: NSManagedObjectContext!
override func setUp() {
super.setUp()
container = NSPersistentContainer(name: "Model", managedObjectModel: Self.sharedModel)
let description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { description, error in
XCTAssertNil(error)
}
context = container.viewContext
}
override func tearDown() {
context = nil
container = nil
super.tearDown()
}
}
```
## Shared Model Pattern
**Problem:** Multiple model instances cause entity description conflicts.
**Error:**
```
Failed to find a unique match for an NSEntityDescription
```
**Solution:** Use shared model instance:
```swift
extension NSManagedObjectModel {
static let shared: NSManagedObjectModel = {
guard let modelURL = Bundle.main.url(forResource: "Model", withExtension: "momd"),
let model = NSManagedObjectModel(contentsOf: modelURL) else {
fatalError("Failed to load model")
}
return model
}()
}
// Use in tests
container = NSPersistentContainer(name: "Model", managedObjectModel: .shared)
```
## Data Generators
Create reproducible test data:
```swift
class TestDataGenerator {
static func createArticle(
name: String = "Test Article",
views: Int = 0,
in context: NSManagedObjectContext
) -> Article {
let article = Article(context: context)
article.name = name
article.views = Int64(views)
article.creationDate = Date()
return article
}
static func createArticles(
count: Int,
in context: NSManagedObjectContext
) -> [Article] {
return (0..<count).map { i in
createArticle(name: "Article \(i)", in: context)
}
}
}
// Usage
func testFetchArticles() throws {
let articles = TestDataGenerator.createArticles(count: 10, in: context)
try context.save()
let fetchRequest = Article.fetchRequest()
let results = try context.fetch(fetchRequest)
XCTAssertEqual(results.count, 10)
}
```
## Testing Fetch Requests
```swift
func testFetchWithPredicate() throws {
// Setup
TestDataGenerator.createArticle(name: "Swift", views: 100, in: context)
TestDataGenerator.createArticle(name: "iOS", views: 50, in: context)
try context.save()
// Test
let fetchRequest = Article.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "views > %d", 75)
let results = try context.fetch(fetchRequest)
// Verify
XCTAssertEqual(results.count, 1)
XCTAssertEqual(results.first?.name, "Swift")
}
```
## Testing Saves
```swift
func testSaveArticle() throws {
let article = TestDataGenerator.createArticle(in: context)
XCTAssertTrue(context.hasChanges)
try context.save()
XCTAssertFalse(context.hasChanges)
// Verify persistence
let fetchRequest = Article.fetchRequest()
let results = try context.fetch(fetchRequest)
XCTAssertEqual(results.count, 1)
XCTAssertEqual(results.first?.name, "Test Article")
}
```
## Testing Validation
```swift
func testValidation() {
let article = Article(context: context)
article.name = "" // Invalid
XCTAssertThrowsError(try context.save()) { error in
let nsError = error as NSError
XCTAssertEqual(nsError.domain, NSCocoaErrorDomain)
}
}
```
## Testing Relationships
```swift
func testArticleCategoryRelationship() throws {
let category = Category(context: context)
category.name = "Swift"
let article = Article(context: context)
article.name = "Test"
article.category = category
try context.save()
XCTAssertEqual(article.category?.name, "Swift")
XCTAssertTrue(category.articles?.contains(article) ?? false)
}
```
## Testing Threading
```swift
func testBackgroundContext() {
let expectation = XCTestExpectation(description: "Background save")
let backgroundContext = container.newBackgroundContext()
backgroundContext.perform {
let article = Article(context: backgroundContext)
article.name = "Background Article"
do {
try backgroundContext.save()
expectation.fulfill()
} catch {
XCTFail("Save failed: \(error)")
}
}
wait(for: [expectation], timeout: 5.0)
}
```
## Testing CloudKit Sync
```swift
func testCloudKitExport() {
let expectation = XCTestExpectation(description: "Export")
let observer = NotificationCenter.default.addObserver(
forName: NSPersistentCloudKitContainer.eventChangedNotification,
object: container,
queue: nil
) { notification in
guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
as? NSPersistentCloudKitContainer.Event else {
return
}
if event.type == .export && event.endDate != nil {
expectation.fulfill()
}
}
let article = Article(context: context)
article.name = "Test"
try? context.save()
wait(for: [expectation], timeout: 60)
NotificationCenter.default.removeObserver(observer)
}
```
## Performance Testing
```swift
func testBatchInsertPerformance() {
measure {
let context = container.newBackgroundContext()
context.performAndWait {
var index = 0
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { object in
guard index < 1000 else { return true }
guard let article = object as? Article else { return true }
article.name = "Article \(index)"
index += 1
return false
}
try? context.execute(batchInsert)
}
}
}
```
## Test Utilities
```swift
extension XCTestCase {
func createTestContainer() -> NSPersistentContainer {
let container = NSPersistentContainer(
name: "Model",
managedObjectModel: .shared
)
let description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
container.persistentStoreDescriptions = [description]
let expectation = self.expectation(description: "Load store")
container.loadPersistentStores { _, error in
XCTAssertNil(error)
expectation.fulfill()
}
waitForExpectations(timeout: 5.0)
return container
}
}
```
## Best Practices
1. **Use in-memory stores** - Fast, isolated tests
2. **Use shared model** - Avoid entity description conflicts
3. **Create data generators** - Reproducible test data
4. **Test on background contexts** - Verify threading
5. **Use expectations** - For asynchronous operations
6. **Measure performance** - Use `measure` blocks
7. **Clean up** - Reset context between tests
8. **Test validation** - Verify business rules
9. **Test relationships** - Ensure integrity
10. **Test migrations** - Verify upgrade paths
## Summary
- Use in-memory stores for fast tests
- Share model instance to avoid conflicts
- Create data generators for reproducible tests
- Test fetch requests, saves, validation, and relationships
- Use expectations for async operations
- Measure performance with `measure` blocks
- Test threading with background contexts
- Test CloudKit sync with event notifications

View File

@@ -0,0 +1,589 @@
# Threading and Concurrency
Core Data threading rules are strict but essential for data integrity. This guide covers safe multi-threading patterns, common pitfalls, and debugging techniques.
## The Golden Rule
**Never pass `NSManagedObject` instances between threads. Always use `NSManagedObjectID`.**
```swift
// WRONG: Passing object between contexts
let article = viewContext.object(...)
backgroundContext.perform {
article.name = "Updated" // CRASH!
}
// CORRECT: Pass object ID
let objectID = article.objectID
backgroundContext.perform {
guard let article = try? backgroundContext.existingObject(with: objectID) as? Article else { return }
article.name = "Updated" // Safe!
try? backgroundContext.save()
}
```
## Why NSManagedObjectID is Thread-Safe
`NSManagedObjectID` is immutable and thread-safe. It's a unique identifier that works across contexts and threads.
```swift
// Object IDs are thread-safe
let objectID: NSManagedObjectID = article.objectID
// Can be passed to any thread/context
DispatchQueue.global().async {
let context = container.newBackgroundContext()
context.perform {
if let article = try? context.existingObject(with: objectID) as? Article {
// Work with article safely
}
}
}
```
## Context Types and Concurrency
### View Context (Main Queue)
Runs on the main thread. Use for all UI operations.
```swift
let viewContext = container.viewContext
viewContext.perform {
// Runs on main thread
let article = Article(context: viewContext)
article.name = "New Article"
try? viewContext.save()
}
```
**Characteristics:**
- Main queue concurrency type
- Runs on main thread
- Use for UI-related operations only
- Keep operations lightweight
### Background Context (Private Queue)
Runs on a private queue. Use for heavy work.
```swift
let backgroundContext = container.newBackgroundContext()
backgroundContext.perform {
// Runs on private background queue
for i in 0..<1000 {
let article = Article(context: backgroundContext)
article.name = "Article \(i)"
}
try? backgroundContext.save()
}
```
**Characteristics:**
- Private queue concurrency type
- Runs on background thread
- Use for imports, exports, batch operations
- Doesn't block UI
## perform vs performAndWait
### perform (Asynchronous - Preferred)
```swift
context.perform {
// Work happens asynchronously
let article = Article(context: context)
try? context.save()
}
// Code here runs immediately, before perform block finishes
```
**Benefits:**
- Non-blocking
- Better performance
- Recommended for most cases
### performAndWait (Synchronous - Use Sparingly)
```swift
context.performAndWait {
// Work happens synchronously
let article = Article(context: context)
try? context.save()
}
// Code here runs after perform block finishes
```
**Caution:**
- Blocks the calling thread
- Can block main thread even for background contexts
- Use only when you need the result immediately
**Example of blocking behavior:**
```swift
// Called from main thread
let backgroundContext = container.newBackgroundContext()
// This BLOCKS the main thread!
backgroundContext.performAndWait {
// Heavy work here blocks UI
for i in 0..<10000 {
let article = Article(context: backgroundContext)
}
}
```
## Common Threading Patterns
### Pattern 1: Background Import
```swift
func importArticles(_ data: [ArticleData]) {
let backgroundContext = container.newBackgroundContext()
backgroundContext.perform {
for item in data {
let article = Article(context: backgroundContext)
article.name = item.name
article.content = item.content
}
do {
try backgroundContext.save()
} catch {
print("Failed to save: \(error)")
}
}
}
```
### Pattern 2: Update Object from Background
```swift
func updateArticle(_ article: Article, newName: String) {
let objectID = article.objectID
let backgroundContext = container.newBackgroundContext()
backgroundContext.perform {
guard let article = try? backgroundContext.existingObject(with: objectID) as? Article else {
return
}
article.name = newName
try? backgroundContext.save()
}
}
```
### Pattern 3: Fetch in Background, Update UI on Main
```swift
func loadArticles(completion: @escaping ([Article]) -> Void) {
let backgroundContext = container.newBackgroundContext()
backgroundContext.perform {
let fetchRequest = Article.fetchRequest()
guard let articles = try? backgroundContext.fetch(fetchRequest) else {
return
}
// Get object IDs (thread-safe)
let objectIDs = articles.map { $0.objectID }
// Switch to main context for UI update
DispatchQueue.main.async {
let viewContext = self.container.viewContext
let mainArticles = objectIDs.compactMap {
try? viewContext.existingObject(with: $0) as? Article
}
completion(mainArticles)
}
}
}
```
### Pattern 4: Batch Delete with Object IDs
```swift
func deleteArticles(_ articles: [Article]) {
let objectIDs = articles.map { $0.objectID }
let backgroundContext = container.newBackgroundContext()
backgroundContext.perform {
for objectID in objectIDs {
guard let article = try? backgroundContext.existingObject(with: objectID) else {
continue
}
backgroundContext.delete(article)
}
try? backgroundContext.save()
}
}
```
## Context Hierarchy and Parent Contexts
### Child Context Pattern
```swift
// Parent context (view context)
let parentContext = container.viewContext
// Child context for editing
let childContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
childContext.parent = parentContext
// Make changes in child
let article = childContext.object(with: articleID) as! Article
article.name = "Updated"
// Save to parent (not to disk yet)
try? childContext.save()
// Save parent to persist
try? parentContext.save()
```
**Benefits:**
- Can discard changes by not saving child
- Useful for forms/editing
- Isolates changes
**Caution:**
- Adds complexity
- Two saves required for persistence
- Parent must be saved for changes to persist
## Debugging Threading Issues
### Enable Concurrency Debug
Add launch argument:
```
-com.apple.CoreData.ConcurrencyDebug 1
```
**What it catches:**
- Objects accessed from wrong thread
- Contexts used from wrong queue
- Thread safety violations
**Example error:**
```
CoreData: error: Serious application error.
An exception was caught from the delegate of NSFetchedResultsController during a call to -controllerDidChangeContent:.
*** -[NSManagedObjectContext performSelector:withObject:] called from thread which is not the context's thread with userInfo (null)
```
### Common Threading Errors
#### Error 1: Accessing Object from Wrong Context
```swift
// Wrong
let article = viewContext.object(...)
backgroundContext.perform {
print(article.name) // CRASH!
}
// Correct
let objectID = article.objectID
backgroundContext.perform {
if let article = try? backgroundContext.existingObject(with: objectID) as? Article {
print(article.name)
}
}
```
#### Error 2: Not Using perform
```swift
// Wrong
let backgroundContext = container.newBackgroundContext()
let article = Article(context: backgroundContext) // CRASH!
// Correct
let backgroundContext = container.newBackgroundContext()
backgroundContext.perform {
let article = Article(context: backgroundContext)
}
```
#### Error 3: Passing Context Between Threads
```swift
// Wrong
DispatchQueue.global().async {
try? viewContext.save() // CRASH!
}
// Correct
viewContext.perform {
try? viewContext.save()
}
```
## Merging Changes Between Contexts
### Automatic Merging
Enable automatic merging from parent:
```swift
context.automaticallyMergesChangesFromParent = true
```
**Benefits:**
- Changes from other contexts automatically merge
- No manual merge code needed
- Recommended for most cases
### Manual Merging
Listen for save notifications:
```swift
NotificationCenter.default.addObserver(
self,
selector: #selector(contextDidSave),
name: .NSManagedObjectContextDidSave,
object: backgroundContext
)
@objc func contextDidSave(_ notification: Notification) {
viewContext.perform {
viewContext.mergeChanges(fromContextDidSave: notification)
}
}
```
## Async/Await with Core Data (iOS 15+)
### Using async/await
```swift
func fetchArticles() async throws -> [Article] {
let context = container.newBackgroundContext()
return try await context.perform {
let fetchRequest = Article.fetchRequest()
return try context.fetch(fetchRequest)
}
}
// Usage
Task {
do {
let articles = try await fetchArticles()
// Update UI with articles
} catch {
print("Failed to fetch: \(error)")
}
}
```
### Saving with async/await
```swift
func saveArticle(name: String) async throws {
let context = container.newBackgroundContext()
try await context.perform {
let article = Article(context: context)
article.name = name
try context.save()
}
}
```
## Performance Considerations
### Context Reuse
```swift
// Bad: Creating new context for each operation
func updateArticle1() {
let context = container.newBackgroundContext()
context.perform { /* ... */ }
}
func updateArticle2() {
let context = container.newBackgroundContext() // New context!
context.perform { /* ... */ }
}
// Better: Reuse context for related operations
class DataManager {
private lazy var backgroundContext = container.newBackgroundContext()
func updateArticle1() {
backgroundContext.perform { /* ... */ }
}
func updateArticle2() {
backgroundContext.perform { /* ... */ }
}
}
```
### Context Reset
For long-running contexts, periodically reset to free memory:
```swift
backgroundContext.perform {
for (index, data) in largeDataset.enumerated() {
let article = Article(context: backgroundContext)
article.name = data.name
if index % 100 == 0 {
try? backgroundContext.save()
backgroundContext.reset() // Clear memory
}
}
}
```
## Thread Confinement
Each context is confined to its queue. You can call `perform` from any thread, but all Core Data work must run inside `perform`/`performAndWait` on that context.
```swift
let context = container.newBackgroundContext()
// Allowed: scheduling work from anywhere
DispatchQueue.global().async {
context.perform {
// Work executes on context's queue
}
}
DispatchQueue.main.async {
context.perform {
// Also executes on context's queue
}
}
// Wrong: touching the context or its objects outside perform
DispatchQueue.global().async {
let article = Article(context: context) // Not inside perform
try? context.save() // Not inside perform
}
```
## Common Pitfalls
### ❌ Passing Objects Directly
```swift
func updateInBackground(_ article: Article) {
backgroundContext.perform {
article.name = "Updated" // CRASH!
}
}
```
### ❌ Not Using perform
```swift
let backgroundContext = container.newBackgroundContext()
let article = Article(context: backgroundContext) // CRASH!
```
### ❌ Accessing UI from Background Context
```swift
backgroundContext.perform {
let articles = try? backgroundContext.fetch(Article.fetchRequest())
tableView.reloadData() // CRASH! Wrong thread
}
```
### ❌ Using performAndWait on Main Thread
```swift
// On main thread
backgroundContext.performAndWait {
// Heavy work - blocks UI!
}
```
### ✅ Correct Patterns
```swift
// Pass object IDs
func updateInBackground(_ article: Article) {
let objectID = article.objectID
backgroundContext.perform {
guard let article = try? backgroundContext.existingObject(with: objectID) as? Article else {
return
}
article.name = "Updated"
try? backgroundContext.save()
}
}
// Always use perform
let backgroundContext = container.newBackgroundContext()
backgroundContext.perform {
let article = Article(context: backgroundContext)
}
// Update UI on main thread
backgroundContext.perform {
let articles = try? backgroundContext.fetch(Article.fetchRequest())
let objectIDs = articles?.map { $0.objectID } ?? []
DispatchQueue.main.async {
// Update UI with objectIDs
}
}
// Use perform (async) instead of performAndWait
backgroundContext.perform {
// Heavy work doesn't block UI
}
```
## Testing Threading
```swift
func testThreadSafety() {
let expectation = XCTestExpectation(description: "Background save")
let objectID = article.objectID
let backgroundContext = container.newBackgroundContext()
backgroundContext.perform {
guard let article = try? backgroundContext.existingObject(with: objectID) as? Article else {
XCTFail("Failed to fetch article")
return
}
article.name = "Updated"
do {
try backgroundContext.save()
expectation.fulfill()
} catch {
XCTFail("Failed to save: \(error)")
}
}
wait(for: [expectation], timeout: 5.0)
}
```
## Summary
1. **Never pass NSManagedObject between contexts** - Always use NSManagedObjectID
2. **Always use `perform` or `performAndWait`** - Never access context directly
3. **Prefer `perform` over `performAndWait`** - Avoid blocking
4. **Use view context for UI only** - Heavy work in background contexts
5. **Enable `-com.apple.CoreData.ConcurrencyDebug 1`** - Catch threading violations
6. **Enable `automaticallyMergesChangesFromParent`** - Automatic change propagation
7. **Use async/await on iOS 15+** - Cleaner asynchronous code
8. **Reset contexts periodically** - Free memory in long-running operations
9. **One context per queue** - Don't share contexts across queues
10. **Test threading behavior** - Verify thread safety in tests

View File

@@ -0,0 +1,760 @@
---
name: swiftui-live-activities
description: Design and implement Live Activities in SwiftUI for iOS. Covers ActivityKit, WidgetKit, Dynamic Island layouts, Lock Screen presentations, push notifications, animations, HIG compliance, and accessibility. Use when building, reviewing, or debugging Live Activities, Dynamic Island UI, or real-time notification features.
license: MIT
metadata:
version: "1.0"
---
# Live Activities in SwiftUI
**Use this skill for any Live Activities work — designing layouts, implementing lifecycle code, configuring push notifications, or reviewing Dynamic Island UI.**
---
## References
Key Apple documentation pages (fetch as needed):
```
https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities
https://developer.apple.com/documentation/activitykit/creating-custom-views-for-live-activities
https://developer.apple.com/documentation/widgetkit/adding-interactivity-to-widgets-and-live-activities
https://developer.apple.com/documentation/widgetkit/animating-data-updates-in-widgets-and-live-activities
https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications
```
Sample code: Emoji Rangers — `https://developer.apple.com/documentation/WidgetKit/emoji-rangers-supporting-live-activities-interactivity-and-animations`
WWDC: Session 10028 (Bring widgets to life), Session 10185 (Live Activities push notifications)
---
## 1. Architecture
Live Activities span **two targets**:
```
Widget Extension (UI) App Target (Lifecycle)
───────────────────── ─────────────────────
ActivityConfiguration ◄── data ── ActivityAttributes
├── Lock Screen view │
└── DynamicIsland { ├── Activity.request()
├── Expanded (4 regions) ├── activity.update()
├── compactLeading ├── activity.end()
├── compactTrailing ├── pushTokenUpdates
└── minimal └── activityStateUpdates
}
```
The **widget extension** renders the UI using WidgetKit + SwiftUI. The **app target** manages the lifecycle via ActivityKit — requesting, updating, and ending activities. Push notifications from APNs can also drive lifecycle changes.
---
## 2. ActivityAttributes — Data Model
Define a struct conforming to `ActivityAttributes` with static properties and a nested `ContentState` for dynamic data.
```swift
import ActivityKit
struct DeliveryAttributes: ActivityAttributes {
// Static never changes
let orderNumber: Int
let restaurantName: String
// Dynamic changes with each update
struct ContentState: Codable, Hashable {
var driverName: String
var estimatedMinutes: Int
var status: Status
}
}
extension DeliveryAttributes {
enum Status: String, Codable {
case preparing, enRoute, delivered
}
}
```
**Constraint**: Encoded `ContentState` must not exceed **4 KB**.
---
## 3. ActivityConfiguration — Full UI
One `ActivityConfiguration` defines every presentation the system needs. This lives in the widget extension.
```swift
import WidgetKit
import SwiftUI
struct DeliveryActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DeliveryAttributes.self) { context in
// Lock Screen (and Home Screen banner on non-Dynamic Island devices)
LockScreenDeliveryView(
orderNumber: context.attributes.orderNumber,
state: context.state,
isStale: context.isStale
)
.activityBackgroundTint(Color.indigo.opacity(0.3))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
DynamicIsland {
// Expanded (touch & hold)
DynamicIslandExpandedRegion(.leading) {
Label(context.state.driverName, systemImage: "person.circle.fill")
.font(.caption)
}
DynamicIslandExpandedRegion(.trailing) {
Label("\(context.state.estimatedMinutes) min", systemImage: "clock")
.font(.caption)
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.status.rawValue)
.font(.headline)
}
DynamicIslandExpandedRegion(.bottom) {
HStack {
Text("Order #\(context.attributes.orderNumber)")
Spacer()
Text(context.attributes.restaurantName)
}
.font(.caption2)
.foregroundStyle(.secondary)
}
} compactLeading: {
// Compact leading (left of TrueDepth)
Image(systemName: "takeoutbag.and.cup.and.straw.fill")
.foregroundStyle(.orange)
} compactTrailing: {
// Compact trailing (right of TrueDepth)
Text("\(context.state.estimatedMinutes) min")
.font(.caption2)
.fontWeight(.heavy)
} minimal: {
// Minimal (when 2+ apps active)
Image(systemName: "takeoutbag.and.cup.and.straw.fill")
.foregroundStyle(.orange)
}
.keylineTint(.orange)
}
.supplementalActivityFamilies([.small, .medium])
}
}
```
---
## 4. Dynamic Island Layouts
### Presentation Types
| Presentation | When shown | Size constraint |
|---|---|---|
| **Compact** (leading + trailing) | Single active Live Activity | Very small, icon + 1-2 text elements |
| **Minimal** | Multiple apps active, StandBy | Tiny, single icon or word |
| **Expanded** | Touch & hold compact/minimal | 4 configurable regions |
### Expanded Regions
```
┌──────────────────────────────────┐
│ .leading │ .center │ .trailing │ ← beside TrueDepth camera
│ │ │ │
│ ┌─────────┴─────────┴─────────┐ │
│ │ .bottom │ │ ← below everything else
│ └──────────────────────────────┘ │
└──────────────────────────────────┘
```
```swift
DynamicIsland {
DynamicIslandExpandedRegion(.leading, priority: 1) { ... }
DynamicIslandExpandedRegion(.trailing, priority: 1) { ... }
DynamicIslandExpandedRegion(.center) { ... }
DynamicIslandExpandedRegion(.bottom) { ... }
}
```
**Priority**: The region with the highest priority gets full width. Equal priority → equal space.
**Vertical placement**: Use `.dynamicIsland(verticalPlacement: .belowIfTooWide)` to wrap leading/trailing content below the camera when too wide.
### Content Margins
```swift
DynamicIsland { ... }
.contentMargins(.trailing, 8, for: .expanded)
.contentMargins(.leading, 16, for: .expanded)
```
Inner `.contentMargins` takes precedence over outer. Avoid placing content at the very edges.
### Keyline Tint
```swift
.keylineTint(.orange) // Subtle border tint in Dark Mode on the Dynamic Island
```
---
## 5. Lock Screen Presentation
- **Max height**: 160 points (system truncates beyond this)
- **Banner mode**: On unlocked devices without Dynamic Island, the Lock Screen view appears as a top banner when the update includes an `AlertConfiguration`
- **StandBy**: The Lock Screen view scales to fill the whole display. Detect it:
```swift
@Environment(\.isActivityFullscreen) var isFullscreen
```
### Background
```swift
// Custom background
.activityBackgroundTint(Color.indigo.opacity(0.25))
// System action button text color
.activitySystemActionForegroundColor(.white)
```
---
## 6. Supplemental Activity Families
```swift
.supplementalActivityFamilies([.small, .medium])
```
| Family | Device |
|---|---|
| `.small` | Apple Watch Smart Stack, CarPlay Home Screen |
| `.medium` | StandBy on iPhone/iPad — expanded Lock Screen layout |
Switch layouts with the environment value:
```swift
@Environment(\.activityFamily) var activityFamily
var body: some View {
switch activityFamily {
case .small: SmallSupplementalView(context: context)
case .medium: MediumSupplementalView(context: context)
default: LockScreenView(context: context)
}
}
```
---
## 7. Color & Styling Rules
### Dynamic Island
| Property | Rule |
|---|---|
| Background | **Always black** — cannot be changed |
| Text | White |
| Tint | `.keylineTint(_:)` for subtle border color in Dark Mode |
### Lock Screen
| Property | Rule |
|---|---|
| Light Mode | Solid white background by default |
| Dark Mode | Solid black background by default |
| Custom | `.activityBackgroundTint(_:)` with optional opacity |
| Action text | `.activitySystemActionForegroundColor(_:)` |
### Always-On Display
The system dims the screen and renders as Dark Mode. Animations are disabled. Use:
```swift
@Environment(\.isLuminanceReduced) var isLuminanceReduced
// Avoid animated transitions when luminance is reduced
if !isLuminanceReduced {
content.transition(.opacity)
}
```
---
## 8. Lifecycle Management
### Start
```swift
let attributes = DeliveryAttributes(orderNumber: 1234, restaurantName: "Toscana")
let initialState = DeliveryAttributes.ContentState(
driverName: "Marco",
estimatedMinutes: 15,
status: .preparing
)
let content = ActivityContent(
state: initialState,
staleDate: Date().addingTimeInterval(1800),
relevanceScore: 100
)
do {
let activity = try Activity.request(
attributes: attributes,
content: content,
pushType: .token
)
} catch {
// Handle: device may have reached simultaneous activity limit
}
```
### Transient (iOS 18+)
Auto-ends when the user collapses the Dynamic Island or locks the device:
```swift
try Activity.request(
attributes: attributes,
content: content,
pushType: nil,
style: .transient
)
```
### Scheduled
```swift
try Activity.request(
attributes: attributes,
content: content,
pushType: .token,
alertConfiguration: AlertConfiguration(
title: "Game starting",
body: "Your team is playing now",
sound: .default
),
startDate: gameDate // AlertConfiguration is required when scheduling
)
```
### Update
```swift
let updatedState = DeliveryAttributes.ContentState(
driverName: "Marco",
estimatedMinutes: 5,
status: .enRoute
)
let updatedContent = ActivityContent(
state: updatedState,
staleDate: Date().addingTimeInterval(1800),
relevanceScore: 80
)
await activity.update(updatedContent)
// With alert (lights up the device, shows expanded/banner):
let alert = AlertConfiguration(title: "Driver nearby", body: "Marco is 5 minutes away", sound: .default)
await activity.update(updatedContent, alertConfiguration: alert)
```
### End
```swift
let finalContent = ActivityContent(state: finalState, staleDate: nil)
await activity.end(finalContent, dismissalPolicy: .default)
// Dismissal policies: .default (removes after a few seconds), .immediate, .after(Date)
```
### Observe
```swift
// Content updates from push notifications
Task {
for await content in activity.contentUpdates {
updateUI(content.state)
}
}
// State changes (.active .stale .ended .dismissed)
Task {
for await state in activity.activityStateUpdates {
if state == .stale { showStaleBanner() }
}
}
// Push token changes
Task {
for await token in activity.pushTokenUpdates {
sendTokenToServer(token)
}
}
// All ongoing activities for the app
let allActive = Activity<DeliveryAttributes>.activities
```
---
## 9. Animations
### Default Behavior
| Content type | Default animation |
|---|---|
| Text | Blurred content transition |
| Images / SF Symbols | Default content transition |
| Add/remove views | Fade in/out |
### Custom (iOS 17+, max 2 seconds)
```swift
// Content transition for numeric text
Text("\(minutes) min")
.contentTransition(.numericText())
.animation(.spring(duration: 0.2), value: minutes)
// View transitions
MyView()
.transition(.push(from: .trailing))
.animation(.easeInOut(duration: 0.3), value: state)
// Supported transitions: .opacity, .move(edge:), .slide, .push(from:),
// or combinations thereof
AnyTransition.asymmetric(
insertion: .push(from: .bottom),
removal: .opacity
)
```
### Disable Animations
```swift
// For views, pass nil to animation
.animation(nil, value: someValue)
// For transitions, use identity
.transition(.identity)
```
### Always-On Display
Animations are **completely disabled** on Always-On displays. Always check `isLuminanceReduced`.
---
## 10. Interactivity — Buttons & Toggles
Uses **App Intents** — not closures or action handlers.
```swift
import AppIntents
struct CheckInIntent: AppIntent {
static var title: LocalizedStringResource = "Check In"
func perform() async throws -> some IntentResult {
// Perform the action (update the Live Activity)
return .result()
}
}
// In the Live Activity view:
Button(intent: CheckInIntent()) {
Label("Check In", systemImage: "figure.wave")
}
Toggle(intent: MuteNotificationsIntent()) {
Label("Mute", systemImage: "bell.slash")
}
```
**Limitation**: Buttons and toggles don't work in CarPlay.
**LiveActivityIntent**: Conform to `LiveActivityIntent` to allow starting Live Activities from Siri, Shortcuts, or App Intents.
---
## 11. Push Notifications
### Setup
1. Add Push Notifications capability in Xcode
2. Add `NSSupportsLiveActivities``YES` in `Info.plist`
3. For frequent updates: add `NSSupportsLiveActivitiesFrequentUpdates``YES`
### Token-based
```swift
// Start with push token support
let activity = try Activity.request(attributes: ..., content: ..., pushType: .token)
// Observe token it may change during the activity's lifetime
Task {
for await token in activity.pushTokenUpdates {
let tokenString = token.reduce("") { $0 + String(format: "%02x", $1) }
await server.register(token: tokenString, for: activity.id)
}
}
```
### Channel-based (iOS 18+)
```swift
try Activity.request(attributes: ..., content: ..., pushType: .channel("channel-uuid"))
```
Allows broadcasting updates to many devices subscribed to the same channel.
### Push-to-Start (iOS 18+)
```swift
// No active activity needed observe the push-to-start token
Task {
for await token in Activity<DeliveryAttributes>.pushToStartTokenUpdates {
let tokenString = token.reduce("") { $0 + String(format: "%02x", $1) }
await server.registerPushToStart(token: tokenString)
}
}
```
### APNs Headers
```
apns-push-type: liveactivity
apns-topic: <bundleID>.push-type.liveactivity (token-based)
apns-channel-id: <channelId> (channel-based)
apns-priority: 5 (low, budget-free) | 10 (high, counts toward budget)
```
### JSON Payload
```json
{
"aps": {
"timestamp": 1685952000,
"event": "update",
"content-state": {
"driverName": "Marco",
"estimatedMinutes": 5,
"status": "enRoute"
},
"relevance-score": 100,
"stale-date": 1685955600,
"alert": {
"title": { "loc-key": "%@ is nearby", "loc-args": ["Marco"] },
"body": { "loc-key": "Arriving in %@ minutes", "loc-args": ["5"] },
"sound": "chime.mp4"
}
}
}
```
- `event`: `"update"` or `"end"`
- `content-state`: Must exactly match your `ContentState` struct — no custom JSON encoding strategies
- `timestamp`: Current time in seconds since 1970 (system shows the most recent update)
- `dismissal-date`: When to remove an ended activity from the Lock Screen
### Priority Budget
- Priority `5`: Does **not** count toward hourly budget — use for routine updates
- Priority `10`: Counts toward budget — use for critical, time-sensitive updates
- Mix `5` and `10` to avoid throttling
- Enable `NSSupportsLiveActivitiesFrequentUpdates` for high-frequency use cases (users can disable in Settings)
---
## 12. Authorization & Availability
```swift
// Synchronous check
if ActivityAuthorizationInfo().areActivitiesEnabled {
showStartButton()
} else {
showEnableInstructions()
}
// Asynchronous observation
Task {
for await enabled in ActivityAuthorizationInfo().activityEnablementUpdates {
updateUI(enabled)
}
}
// Frequent push notification status
if ActivityAuthorizationInfo().frequentPushesEnabled {
configureHighFrequencyUpdates()
}
Task {
for await enabled in ActivityAuthorizationInfo().frequentPushEnablementUpdates {
adjustPushFrequency(enabled)
}
}
```
- Live Activities are **iPhone and iPad only**
- Users can disable them per-app in Settings
- Always handle errors when starting (device may reach activity limit)
---
## 13. Accessibility
Every view in every presentation needs accessibility labels:
```swift
Image(systemName: "takeoutbag.and.cup.and.straw.fill")
.accessibilityLabel("Food delivery is in progress")
Text("\(minutes) min")
.accessibilityLabel("Estimated arrival: \(minutes) minutes")
```
Add labels to compact, minimal, expanded, and Lock Screen views alike.
---
## 14. Relevance & Staleness
### Relevance Score
```swift
ActivityContent(state: ..., staleDate: ..., relevanceScore: 100)
```
| Score | Effect |
|---|---|
| Higher | Appears in Dynamic Island, top of Lock Screen list |
| Equal or unset | First-started wins |
Use relative values: `100` for important, `50` for routine.
### Stale Date
```swift
ActivityContent(state: ..., staleDate: Date().addingTimeInterval(600))
```
When the stale date passes:
- `activityState``.stale`
- `context.isStale``true`
- Show a "last updated" indicator or dim the content
On each update, advance the stale date to prevent it from appearing stale prematurely.
---
## 15. Xcode Previews
```swift
#Preview("Lock Screen", as: .content, using: DeliveryAttributes(...)) {
DeliveryActivityWidget()
} contentStates: {
DeliveryAttributes.ContentState(...)
}
#Preview("Dynamic Island Expanded", as: .dynamicIsland(.expanded), using: DeliveryAttributes(...)) {
DeliveryActivityWidget()
} contentStates: {
DeliveryAttributes.ContentState(...)
}
#Preview("Dynamic Island Compact", as: .dynamicIsland(.compact), using: DeliveryAttributes(...)) {
DeliveryActivityWidget()
} contentStates: {
DeliveryAttributes.ContentState(...)
}
#Preview("Dynamic Island Minimal", as: .dynamicIsland(.minimal), using: DeliveryAttributes(...)) {
DeliveryActivityWidget()
} contentStates: {
DeliveryAttributes.ContentState(...)
}
```
---
## 16. Constraints Reference
| Constraint | Value |
|---|---|
| Content state payload | ≤ 4 KB |
| Lock Screen height | ≤ 160 points |
| Animation duration | ≤ 2 seconds |
| Dynamic Island background | Always black |
| Compact leading view | Icon + 1-2 text elements max |
| Compact trailing view | Icon + 1-2 text elements max |
| Minimal view | Single icon or very short text |
| Platform support | iPhone, iPad (not macOS, not visionOS, not tvOS) |
| Simultaneous activities | Limited per device (exact number varies) |
---
## 17. API Quick Reference
| Type | Framework | Purpose |
|---|---|---|
| `ActivityAttributes` | ActivityKit | Static + dynamic data definition |
| `ActivityContent` | ActivityKit | State + staleDate + relevanceScore |
| `Activity` | ActivityKit | Lifecycle: request, update, end, observe |
| `ActivityConfiguration` | WidgetKit | Widget extension UI configuration |
| `DynamicIsland` | WidgetKit | Dynamic Island layout container |
| `DynamicIslandExpandedRegion` | WidgetKit | Expanded positioning (.leading, .trailing, .center, .bottom) |
| `DynamicIslandExpandedRegionPosition` | WidgetKit | Region position enum |
| `ActivityViewContext` | WidgetKit | Context passed to views (state, isStale, attributes) |
| `ActivityAuthorizationInfo` | ActivityKit | Permission checks |
| `ActivityState` | ActivityKit | .active, .stale, .ended, .dismissed |
| `ActivityFamily` | WidgetKit | .small, .medium for supplemental |
| `ActivityPreviewViewKind` | WidgetKit | Xcode preview configurations |
| `PushType` | ActivityKit | .token or .channel(String) |
| `AlertConfiguration` | ActivityKit | Alert when updating or scheduling |
| `LiveActivityIntent` | AppIntents | Base protocol for LA app intents |
---
## 18. Checklist — Before Shipping a Live Activity
- [ ] All 4 Dynamic Island presentations implemented (compact leading/trailing, minimal, expanded)
- [ ] Lock Screen presentation fits within 160pt
- [ ] `.supplementalActivityFamilies([.small, .medium])` added
- [ ] Accessibility labels on every view in every presentation
- [ ] Stale date configured and handled in the UI
- [ ] Relevance scores set when multiple activities can run
- [ ] Always-On display handled (`isLuminanceReduced`)
- [ ] Errors handled when starting (activity limit reached)
- [ ] Authorization checked before showing start UI
- [ ] Push notification payload matches ContentState exactly — no custom JSON strategies
- [ ] Push priority mix planned (5 for routine, 10 for critical)
- [ ] Xcode previews for all presentation types
- [ ] Widget extension target includes the ActivityConfiguration
- [ ] `NSSupportsLiveActivities` = YES in Info.plist
---
## 19. Common Pitfalls
1. **Missing a presentation** — You must provide ALL of: Lock Screen, compactLeading, compactTrailing, minimal, expanded. The system needs every one and will reject the configuration if any is absent.
2. **Dynamic Island background color** — You cannot change it. It's always black. Use `.keylineTint` and white text.
3. **ContentState too large** — The 4 KB limit is strict. Avoid large strings, arrays, or nested objects in the content state.
4. **Custom JSON encoding** — The system uses default `JSONEncoder`/`JSONDecoder` strategies. Custom encoding strategies cause push update failures with no obvious error.
5. **No stale date** — Without a stale date, outdated content persists indefinitely. Always set one and advance it with each update.
6. **Animations on Always-On** — They're silently disabled. Always check `isLuminanceReduced` before relying on animation for critical information.
7. **Forgetting accessibility** — Dynamic Island views are tiny and receive focus from VoiceOver. Every icon and text needs a descriptive label.
8. **Push token lifecycle** — Tokens can change during an activity's lifetime. Always observe `pushTokenUpdates` — never read `pushToken` once and store it permanently.
9. **Priority 10 everything** — Using priority 10 for every update hits the budget quickly. Reserve it for truly urgent updates.
10. **Scheduling without AlertConfiguration** — The `request(attributes:content:pushType:style:alertConfiguration:startDate:)` API requires the alert configuration parameter when scheduling.

View File

@@ -0,0 +1,76 @@
---
name: swift-architecture-skill
description: Swift architecture patterns and playbooks for MVVM, TCA, Clean Architecture, and more.
license: MIT
---
# Swift Architecture Skill
## Overview
Use this skill to pick the best Swift architecture playbook for SwiftUI/UIKit codebases and apply it to the users task.
## Workflow
### Step 1: Analyze the Request Context
Before selecting an architecture, capture:
- task type (new feature, refactor, PR review, debugging)
- UI stack (SwiftUI, UIKit, or mixed)
- scope (single screen, multi-screen, app-wide)
- existing conventions to preserve
### Step 2: Select the Architecture
If the user explicitly names an architecture, treat it as the initial candidate and run a fit check before committing:
- validate against UI stack fit (SwiftUI/UIKit/mixed), state complexity, effect orchestration needs, team familiarity, and existing codebase conventions
- if it fits, proceed with the requested architecture
- if it mismatches key constraints, explicitly explain the mismatch and recommend the closest-fit alternative from `references/selection-guide.md`
- if the user still insists on a mismatched architecture, proceed with a risk-mitigated plan and state the risks up front
When no architecture is named, load `references/selection-guide.md` and infer the best fit from stated constraints (state complexity, team familiarity, testing goals, effect orchestration needs, and framework preferences). Explain the recommendation briefly.
Architecture reference mapping:
- MVVM → `references/mvvm.md`
- MVI → `references/mvi.md`
- TCA → `references/tca.md`
- Clean Architecture → `references/clean-architecture.md`
- VIPER → `references/viper.md`
- Reactive → `references/reactive.md`
- MVP → `references/mvp.md`
- Coordinator → `references/coordinator.md`
### Step 3: Analyze Existing Codebase (When Applicable)
When code already exists:
- detect current architecture and DI style
- note concurrency model (async/await, Combine, GCD, mixed)
- align recommendations to local conventions
### Step 4: Produce Concrete Deliverables
Read the selected architecture reference and convert its guidance into deliverables tailored to the user's request:
- **File and module structure**: directory layout with file names specific to the feature
- **State and dependency boundaries**: concrete types, protocols, and injection points
- **Async strategy**: cancellation, actor isolation, and error paths
- **Testing strategy**: what to test, how to stub dependencies, and example test structure
- **Migration path** (for refactors): incremental steps to move from current to target architecture
- **UI stack adaptation**: where SwiftUI and UIKit guidance should differ for the chosen architecture
### Step 5: Validate with Checklist
End with the architecture-specific PR review checklist from the reference file, adapted to the user's feature.
## Output Requirements
- Keep recommendations scoped to the requested feature or review task.
- Prefer protocol-based dependency injection and explicit state modeling.
- Flag anti-patterns found in existing code and provide direct fixes.
- Include cancellation and error handling in all async flows.
- For explicit architecture requests, include a short fit result (`fit` or `mismatch`) with 1-2 reasons.
- For mismatch cases, include one closest-fit alternative and why it better matches the stated constraints.
- When writing code, include only the patterns relevant to the task — do not dump entire playbooks.
- Treat reference snippets as illustrative by default; add full compile scaffolding only if the user asks for runnable code.
- Ask only minimum blocking questions; otherwise proceed with explicit assumptions stated up front.
- When reviewing PRs, use the architecture-specific checklist and call out specific violations with line-level fixes.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Swift Architecture Skill"
short_description: "Architecture guidance for Swift, SwiftUI, and UIKit iOS projects"
default_prompt: "Use this skill to select and apply the right architecture for Swift, SwiftUI, and UIKit features, then provide concrete implementation guidance and review checks."

View File

@@ -0,0 +1,322 @@
# Clean Architecture Playbook (Swift + SwiftUI/UIKit)
Use this reference when a Swift codebase needs strict layer boundaries and use-case-driven business logic.
## Core Dependency Rule
Dependencies point inward:
```text
Frameworks / UI
->
Interface Adapters
->
Use Cases
->
Entities (Domain)
```
Rules:
- inner layers must not import or depend on outer layers
- domain remains pure Swift
- frameworks are implementation details and replaceable
## Canonical Layer Layout
```text
Domain/
Entities/
UseCases/
Data/
Repositories/
API/
Persistence/
Presentation/
Features/
App/
```
Guidance:
- keep entities and use-case protocols in `Domain`
- keep repository implementations and external adapters in `Data`
- keep views/view models/controllers in `Presentation`
- keep DI composition root and app bootstrap in `App`
## Entities
Entities model core business concepts and rules.
```swift
struct User: Equatable {
let id: UUID
let name: String
}
```
Rules:
- no SwiftUI/UIKit imports
- no persistence or network behavior
- avoid framework-specific types unless unavoidable
## Use Cases
Use cases orchestrate business actions through abstractions.
```swift
protocol LoadUserUseCase {
func execute(id: UUID) async throws -> User
}
final class LoadUser: LoadUserUseCase {
private let repository: UserRepository
init(repository: UserRepository) {
self.repository = repository
}
func execute(id: UUID) async throws -> User {
try await repository.fetch(id: id)
}
}
```
Rules:
- one business responsibility per use case
- no UI details
- no direct framework usage unless abstracted
## Repository Boundary
Define repository protocols in `Domain`; implement them in `Data`.
```swift
protocol UserRepository {
func fetch(id: UUID) async throws -> User
}
```
Data-layer implementations can coordinate:
- API clients
- local persistence
- mapping DTOs to domain entities
## Dependency Injection Pattern
Compose live dependencies in the app or feature assembly layer.
```swift
enum UserFeatureAssembly {
static func makeLoadUserUseCase() -> LoadUserUseCase {
let repository = LiveUserRepository(api: .live)
return LoadUser(repository: repository)
}
}
```
Rules:
- inject protocols into use cases and presentation
- avoid global singletons as hidden dependencies
## DTO to Domain Mapping
Map external models to domain entities at the data-layer boundary, in mappers or repository implementations.
```swift
struct UserDTO: Decodable {
let id: String
let full_name: String
let created_at: String
}
enum UserMapper {
static func toDomain(_ dto: UserDTO) throws -> User {
guard let id = UUID(uuidString: dto.id) else {
throw MappingError.invalidID(dto.id)
}
return User(id: id, name: dto.full_name)
}
}
enum MappingError: Error {
case invalidID(String)
}
final class LiveUserRepository: UserRepository {
private let api: APIClient
init(api: APIClient) {
self.api = api
}
func fetch(id: UUID) async throws -> User {
let dto = try await api.fetchUser(id: id)
return try UserMapper.toDomain(dto)
}
}
```
Rules:
- never expose DTOs beyond the data layer
- test mappers independently for edge cases and invalid input
- keep mapping pure and side-effect-free
## Concurrency and Cancellation
Use structured concurrency in use cases and let cancellation propagate through async calls.
```swift
final class LoadUserProfile: LoadUserProfileUseCase {
private let userRepo: UserRepository
private let postsRepo: PostsRepository
init(userRepo: UserRepository, postsRepo: PostsRepository) {
self.userRepo = userRepo
self.postsRepo = postsRepo
}
func execute(id: UUID) async throws -> UserProfile {
async let user = userRepo.fetch(id: id)
async let posts = postsRepo.fetchRecent(userID: id)
return try await UserProfile(user: user, posts: posts)
}
}
```
Rules:
- prefer `async let` for concurrent independent fetches
- cancellation propagates automatically through `try await`
- use `Task.checkCancellation()` before expensive work if needed
- in presentation, cancel tasks on view disappearance or new request
## Presentation Boundary
Presentation depends on use-case abstractions, not data implementations.
Expected flow:
- View triggers intent/event
- Presentation layer calls `UseCase`
- UseCase returns domain entities
- Presentation maps entities to view state
SwiftUI adaptation:
- use `@Observable`/`ObservableObject` ViewModels that expose view state
- trigger use cases from intent methods on the ViewModel
- keep SwiftUI views declarative and free of use-case/repository calls
UIKit adaptation:
- use Presenter/ViewModel objects owned by view controllers
- convert delegate/target-action events into presenter intents
- keep controllers responsible for rendering only; business coordination stays in presenter/use case layers
## Anti-Patterns and Fixes
1. God use case:
- Smell: single 500+ line use case handling many responsibilities.
- Fix: split by business capability and compose use cases.
2. Presentation imports data layer:
- Smell: feature view model directly uses `LiveRepository` or API client.
- Fix: depend on use-case protocol only.
3. Domain depends on frameworks:
- Smell: domain entities use UI/network/persistence frameworks.
- Fix: keep domain pure and move adapters outward.
4. Repository leaks transport types:
- Smell: presentation receives DTO/network models.
- Fix: map external models to domain entities in data layer.
5. Testing through real infrastructure:
- Smell: unit tests require network/db.
- Fix: test use cases with mocked/stub repositories.
## Testing Strategy
Prioritize:
- use-case unit tests with repository stubs
- mapper tests (DTO <-> domain) in data layer
- presentation tests with mocked use cases
Rules:
- avoid network in unit tests
- assert business behavior at use-case boundary
- keep async tests deterministic using controlled stubs
- test cancellation propagation for long-running use cases
```swift
struct StubUserRepository: UserRepository {
var result: Result<User, Error>
func fetch(id: UUID) async throws -> User {
try result.get()
}
}
@MainActor
final class LoadUserTests: XCTestCase {
func test_execute_returnsUser() async throws {
let expected = User(id: UUID(), name: "Alice")
let sut = LoadUser(repository: StubUserRepository(result: .success(expected)))
let user = try await sut.execute(id: expected.id)
XCTAssertEqual(user, expected)
}
func test_execute_propagatesFailure() async {
let sut = LoadUser(repository: StubUserRepository(result: .failure(TestError.notFound)))
do {
_ = try await sut.execute(id: UUID())
XCTFail("Expected error")
} catch {
XCTAssertTrue(error is TestError)
}
}
func test_execute_cancellationPropagates() async {
let sut = LoadUser(repository: BlockingUserRepository())
// Deterministic because this test class is @MainActor:
// Task { ... } inherits main-actor isolation and does not start executing
// until the main actor yields at await task.value, so cancellation is
// observed immediately. Without @MainActor this pattern is racy.
let task = Task { try await sut.execute(id: UUID()) }
task.cancel()
do {
_ = try await task.value
XCTFail("Expected cancellation")
} catch is CancellationError {
// expected
} catch {
XCTFail("Unexpected error: \(error)")
}
}
}
private actor BlockingUserRepository: UserRepository {
func fetch(id: UUID) async throws -> User {
try await Task.sleep(for: .seconds(60))
return User(id: id, name: "")
}
}
private enum TestError: Error { case notFound }
```
## When to Prefer Clean Architecture
Prefer when:
- app/domain complexity is medium to large
- multiple teams need stable boundaries
- long-term maintainability and replaceable infrastructure matter
Prefer lighter layering when:
- app is small and short-lived
- strict layering overhead is higher than expected benefit
## PR Review Checklist
- Dependency direction points inward only.
- Domain layer is framework-independent.
- Use cases encapsulate business rules and stay focused.
- Presentation does not import data implementations.
- Repository abstractions live at domain boundary.
- Tests isolate use cases from infrastructure.

View File

@@ -0,0 +1,466 @@
# Coordinator Playbook (Swift + SwiftUI/UIKit)
Use this reference when navigation logic needs to be decoupled from individual screens, enabling reusable flows, deep linking, and testable routing without view controllers owning their own transitions.
## Core Concept
A Coordinator owns one navigation flow. It creates and connects screens, passes dependencies, and decides what happens next when a user action triggers a transition.
```text
AppCoordinator
-> AuthCoordinator (owns login/signup flow)
-> MainCoordinator (owns tab/home flow)
-> ProfileCoordinator (owns profile flow)
```
Rules:
- each coordinator owns one flow (a screen, a sub-flow, or a full section)
- screens emit navigation events; coordinators decide what to do with them
- screens do not reference coordinators or push/present directly
- parent coordinators launch child coordinators for nested flows
## Feature Structure
```text
App/
AppCoordinator.swift
Coordinators/
AuthCoordinator.swift
MainCoordinator.swift
ProfileCoordinator.swift
Features/
Auth/
LoginViewModel.swift
LoginView.swift
Profile/
ProfileViewModel.swift
ProfileView.swift
Navigation/
Coordinator.swift (protocol)
NavigationRouter.swift (UIKit helper)
```
## Coordinator Protocol
Define a minimal base contract.
```swift
@MainActor
protocol Coordinator: AnyObject {
var childCoordinators: [Coordinator] { get set }
func start()
}
extension Coordinator {
func addChild(_ coordinator: Coordinator) {
childCoordinators.append(coordinator)
coordinator.start()
}
func removeChild(_ coordinator: Coordinator) {
childCoordinators.removeAll { $0 === coordinator }
}
}
```
Rules:
- retain child coordinators so they are not deallocated mid-flow
- remove child coordinators when the flow they own completes
- `start()` is the single entry point that kicks off the flow
## UIKit Coordinator
For UIKit, wrap a `UINavigationController` in a thin router.
```swift
@MainActor
final class NavigationRouter {
let navigationController: UINavigationController
init(navigationController: UINavigationController = UINavigationController()) {
self.navigationController = navigationController
}
func push(_ viewController: UIViewController, animated: Bool = true) {
navigationController.pushViewController(viewController, animated: animated)
}
func present(_ viewController: UIViewController, animated: Bool = true) {
navigationController.present(viewController, animated: animated)
}
func pop(animated: Bool = true) {
navigationController.popViewController(animated: animated)
}
func popToRoot(animated: Bool = true) {
navigationController.popToRootViewController(animated: animated)
}
}
```
Profile flow coordinator example:
```swift
@MainActor
final class ProfileCoordinator: Coordinator {
var childCoordinators: [Coordinator] = []
private let router: NavigationRouter
private let userRepository: UserRepository
init(router: NavigationRouter, userRepository: UserRepository) {
self.router = router
self.userRepository = userRepository
}
func start() {
let viewModel = ProfileViewModel(
repository: userRepository,
onEditTapped: { [weak self] in self?.showEditProfile() },
onLogoutTapped: { [weak self] in self?.finish() }
)
let viewController = ProfileViewController(viewModel: viewModel)
router.push(viewController)
}
private func showEditProfile() {
let editCoordinator = EditProfileCoordinator(
router: router,
userRepository: userRepository,
onComplete: { [weak self] in self?.removeChild($0) }
)
addChild(editCoordinator)
}
private func finish() {
// Notify parent this flow is done.
}
}
```
## SwiftUI Coordinator
For SwiftUI, model navigation state as a value type and bind it to `NavigationStack`.
```swift
@MainActor
@Observable
final class AppCoordinator: Coordinator {
var childCoordinators: [Coordinator] = []
var path: [AppDestination] = []
var sheet: AppSheet?
private let userRepository: UserRepository
init(userRepository: UserRepository) {
self.userRepository = userRepository
}
func start() {
// Nothing to push root is set at view layer.
}
func showProfile(userID: UUID) {
path.append(.profile(userID))
}
func showSettings() {
sheet = .settings
}
func pop() {
guard !path.isEmpty else { return }
path.removeLast()
}
func dismissSheet() {
sheet = nil
}
}
enum AppDestination: Hashable {
case profile(UUID)
case editProfile(UUID)
}
enum AppSheet: Identifiable {
case settings
var id: String { "\(self)" }
}
```
Root view binds coordinator state to `NavigationStack`:
```swift
struct AppRootView: View {
@State private var coordinator: AppCoordinator
init(coordinator: AppCoordinator) {
self._coordinator = State(initialValue: coordinator)
}
var body: some View {
@Bindable var coordinator = coordinator
NavigationStack(path: $coordinator.path) {
HomeView(
onProfileTapped: { id in coordinator.showProfile(userID: id) },
onSettingsTapped: { coordinator.showSettings() }
)
.navigationDestination(for: AppDestination.self) { destination in
switch destination {
case .profile(let id):
ProfileView(viewModel: makeProfileViewModel(userID: id))
case .editProfile(let id):
EditProfileView(userID: id)
}
}
}
.sheet(item: $coordinator.sheet) { sheet in
switch sheet {
case .settings:
SettingsView(onDismiss: { coordinator.dismissSheet() })
}
}
}
private func makeProfileViewModel(userID: UUID) -> ProfileViewModel {
ProfileViewModel(
userID: userID,
repository: coordinator.userRepository,
onEditTapped: { coordinator.path.append(.editProfile(userID)) }
)
}
}
```
Rules:
- model destinations as a `Hashable` enum so `NavigationStack` can drive them
- model sheets as an `Identifiable` enum to bind `sheet(item:)`
- mutate coordinator state on the main actor
- avoid deep conditional nesting in the `navigationDestination` closure — prefer `switch`
## Child Coordinator Pattern
Parent coordinators own child coordinators for nested flows.
```swift
@MainActor
final class MainCoordinator: Coordinator {
var childCoordinators: [Coordinator] = []
private let router: NavigationRouter
private let userRepository: UserRepository
init(router: NavigationRouter, userRepository: UserRepository) {
self.router = router
self.userRepository = userRepository
}
func start() {
showHome()
}
func showHome() {
let viewModel = HomeViewModel(
onProfileTapped: { [weak self] id in self?.showProfile(userID: id) }
)
let viewController = HomeViewController(viewModel: viewModel)
router.push(viewController)
}
private func showProfile(userID: UUID) {
let profileRouter = NavigationRouter(
navigationController: router.navigationController
)
let coordinator = ProfileCoordinator(
router: profileRouter,
userRepository: userRepository
)
addChild(coordinator)
}
}
```
## Deep Linking
Handle deep links by parsing a URL into a destination and routing directly to it.
Push destinations update `path`; sheet destinations set `sheet`.
```swift
@MainActor
final class DeepLinkHandler {
private let coordinator: AppCoordinator
init(coordinator: AppCoordinator) {
self.coordinator = coordinator
}
func handle(url: URL) {
guard url.scheme == "myapp" else { return }
switch url.host {
case "profile":
guard
let idString = url.pathComponents.dropFirst().first,
let id = UUID(uuidString: idString)
else { return }
coordinator.path = [.profile(id)]
case "settings":
coordinator.sheet = .settings
default:
break
}
}
}
```
## Anti-Patterns and Fixes
1. View controller pushes its own next screen:
- Smell: `ProfileViewController` calls `navigationController?.pushViewController(SettingsViewController(), animated: true)` directly.
- Fix: emit a closure or delegate event; let the Coordinator perform the push.
2. Coordinator retained only by a local variable:
- Smell: parent loses reference to child coordinator; it deallocates mid-flow.
- Fix: add child to `childCoordinators` before calling `start()`.
3. Navigation logic spread across ViewModels:
- Smell: ViewModel holds a reference to `AppCoordinator` and calls `coordinator.showSettings()` directly.
- Fix: inject navigation closures (`onSettingsTapped: () -> Void`) so the ViewModel stays decoupled from the coordinator type.
4. Deep linking bypasses coordinator:
- Smell: `AppDelegate` calls `navigationController.pushViewController(...)` directly on deep link receipt.
- Fix: route all deep links through `DeepLinkHandler``AppCoordinator.handle(url:)`.
5. Coordinator mixing business logic:
- Smell: Coordinator fetches data or applies business rules before routing.
- Fix: keep Coordinator responsible only for navigation; delegate data work to ViewModels/Repositories.
## Testing Strategy
Test Coordinators by verifying navigation state changes for success paths (expected destinations appended), failure paths (unknown inputs handled without crashing), and cancellation-safe pop operations.
Use stub repositories and direct coordinator state inspection to keep tests deterministic.
Avoid sleeps; prefer synchronous state mutations and direct property assertions.
```swift
@MainActor
final class SpyNavigationRouter: NavigationRouter {
var pushedViewControllers: [UIViewController] = []
var presentedViewControllers: [UIViewController] = []
override func push(_ viewController: UIViewController, animated: Bool = true) {
pushedViewControllers.append(viewController)
}
override func present(_ viewController: UIViewController, animated: Bool = true) {
presentedViewControllers.append(viewController)
}
}
@MainActor
final class ProfileCoordinatorTests: XCTestCase {
func test_start_pushesProfileViewController() {
let router = SpyNavigationRouter()
let coordinator = ProfileCoordinator(
router: router,
userRepository: StubUserRepository()
)
coordinator.start()
XCTAssertEqual(router.pushedViewControllers.count, 1)
XCTAssertTrue(router.pushedViewControllers.first is ProfileViewController)
}
func test_showEditProfile_addsChildCoordinator() {
let router = SpyNavigationRouter()
let coordinator = ProfileCoordinator(
router: router,
userRepository: StubUserRepository()
)
coordinator.start()
coordinator.showEditProfileForTesting()
XCTAssertEqual(coordinator.childCoordinators.count, 1)
}
}
@MainActor
final class AppCoordinatorTests: XCTestCase {
func test_showProfile_success_appendsDestination() {
let coordinator = AppCoordinator(userRepository: StubUserRepository())
let id = UUID()
coordinator.showProfile(userID: id)
XCTAssertEqual(coordinator.path, [.profile(id)])
}
func test_pop_removesLastDestination() {
let coordinator = AppCoordinator(userRepository: StubUserRepository())
coordinator.path = [.profile(UUID()), .editProfile(UUID())]
coordinator.pop()
XCTAssertEqual(coordinator.path.count, 1)
}
func test_dismissSheet_clearsSheet() {
let coordinator = AppCoordinator(userRepository: StubUserRepository())
coordinator.sheet = .settings
coordinator.dismissSheet()
XCTAssertNil(coordinator.sheet)
}
func test_deepLink_failure_doesNotCrashOnUnknownScheme() {
let coordinator = AppCoordinator(userRepository: StubUserRepository())
let handler = DeepLinkHandler(coordinator: coordinator)
let unknownURL = URL(string: "https://example.com/profile/123")!
handler.handle(url: unknownURL)
XCTAssertTrue(coordinator.path.isEmpty)
}
func test_pop_cancellation_onEmptyPath_doesNotCrash() {
let coordinator = AppCoordinator(userRepository: StubUserRepository())
XCTAssertTrue(coordinator.path.isEmpty)
coordinator.pop()
XCTAssertTrue(coordinator.path.isEmpty)
}
}
struct StubUserRepository: UserRepository {
func fetchCurrentUser() async throws -> User {
User(id: UUID(), name: "Stub", isPremium: false, joinDate: .now)
}
}
```
Note: `showEditProfileForTesting()` exposes the private routing action for test access — annotate with `#if DEBUG` or use `@testable import` and `internal` access level to keep production code clean.
## When to Prefer Coordinator
Prefer Coordinator when:
- navigation logic is complex (conditional flows, deep linking, multi-step wizards)
- multiple screens need to be reused across different flows
- you want to test routing logic without instantiating full screens
- ViewModels and View Controllers should have zero navigation coupling
Pair with MVVM by injecting navigation closures into ViewModels; pair with MVP by having the Presenter call a Router protocol backed by a Coordinator.
The Coordinator pattern is not an architecture on its own — it is a navigation layer that complements presentation patterns. Prefer it when `UINavigationController` push/present calls scattered across view controllers make flows hard to follow or test.
## PR Review Checklist
- Each coordinator owns one clearly scoped flow.
- Child coordinators are retained in `childCoordinators` before `start()` is called.
- Child coordinators are removed when their flow completes.
- ViewModels and View Controllers receive navigation closures, not coordinator references.
- Navigation state (SwiftUI path/sheet) is modeled as value types.
- Deep link handling routes through the coordinator, not directly to view controllers.
- Tests verify routing state changes without relying on UIKit presentation timing.

View File

@@ -0,0 +1,570 @@
# MVI Playbook (Swift + SwiftUI/UIKit)
Use this reference for strict unidirectional flow and deterministic state transitions.
## Mental Model
```text
Intent -> Reducer -> New State -> View
-> Effect -> Action -> Reducer
```
Core rules:
- Keep one source of truth: `State`.
- Keep reducer logic deterministic.
- Isolate side effects in `Effect`.
- Feed effect output back as `Action`.
## Core Types
### State
- Use value types (`struct`) only.
- Keep state equatable/serializable where practical.
- Store canonical state, not redundant derived values.
```swift
enum Loadable<Value: Equatable>: Equatable {
case idle
case loading
case loaded(Value)
case failed(String)
}
struct CounterState: Equatable {
var load: Loadable<Int> = .idle
var count: Int {
guard case .loaded(let value) = load else { return 0 }
return value
}
}
```
### Intent
- Represent user-driven input only.
- Do not use intents for network responses.
```swift
enum CounterIntent {
case incrementTapped
case decrementTapped
case resetTapped
}
```
### Action
- Represent internal events and effect results.
- Reducer handles actions to complete async loops.
```swift
enum CounterAction {
case incrementResponse(Result<Int, Error>)
case decrementResponse(Result<Int, Error>)
case resetResponse(Result<Int, Error>)
}
```
Action reducer for completing async transitions:
```swift
func reduce(state: inout CounterState, action: CounterAction) {
switch action {
case .incrementResponse(.success(let value)):
state.load = .loaded(value)
case .incrementResponse(.failure(let error)):
state.load = .failed(error.localizedDescription)
case .decrementResponse(.success(let value)):
state.load = .loaded(value)
case .decrementResponse(.failure(let error)):
state.load = .failed(error.localizedDescription)
case .resetResponse(.success(let value)):
state.load = .loaded(value)
case .resetResponse(.failure(let error)):
state.load = .failed(error.localizedDescription)
}
}
```
### Effect
- Encapsulate async side effects.
- Keep effect execution in the store.
```swift
enum Effect<Action> {
case none
case run(() async throws -> Action)
case cancellable(id: AnyHashable, () async throws -> Action)
}
```
## Reducer Pattern
- Reducer over `Intent`: mutate state for immediate transitions and optionally return effect.
- Reducer over `Action`: finish transition from effect output.
- Avoid direct side effects inside reducer branches.
```swift
protocol CounterServicing {
func increment() async throws -> Int
func decrement() async throws -> Int
func reset() async throws -> Int
}
func reduce(
state: inout CounterState,
intent: CounterIntent,
service: CounterServicing
) -> Effect<CounterAction>? {
switch intent {
case .incrementTapped:
state.load = .loading
return .run {
do {
let value = try await service.increment()
return .incrementResponse(.success(value))
} catch {
return .incrementResponse(.failure(error))
}
}
case .decrementTapped:
state.load = .loading
return .run {
do {
let value = try await service.decrement()
return .decrementResponse(.success(value))
} catch {
return .decrementResponse(.failure(error))
}
}
case .resetTapped:
state.load = .loading
return .run {
do {
let value = try await service.reset()
return .resetResponse(.success(value))
} catch {
return .resetResponse(.failure(error))
}
}
}
}
```
This signature is a pragmatic shortcut: passing `service` into `reduce` keeps call sites simple, but the reducer is environment-coupled. If you want stricter MVI purity, make `reduce` return effect descriptors and run them outside the reducer.
```swift
enum CounterEffect {
case increment
case decrement
case reset
}
func reduce(state: inout CounterState, intent: CounterIntent) -> CounterEffect? {
switch intent {
case .incrementTapped:
state.load = .loading
return .increment
case .decrementTapped:
state.load = .loading
return .decrement
case .resetTapped:
state.load = .loading
return .reset
}
}
func run(_ effect: CounterEffect, service: CounterServicing) async -> CounterAction {
do {
switch effect {
case .increment:
return .incrementResponse(.success(try await service.increment()))
case .decrement:
return .decrementResponse(.success(try await service.decrement()))
case .reset:
return .resetResponse(.success(try await service.reset()))
}
} catch {
switch effect {
case .increment:
return .incrementResponse(.failure(error))
case .decrement:
return .decrementResponse(.failure(error))
case .reset:
return .resetResponse(.failure(error))
}
}
}
```
Adapter pattern for wiring the pure `reduce/run` pair into `Store`:
```swift
@MainActor
func makeCounterStore(service: CounterServicing) -> Store<CounterState, CounterIntent, CounterAction> {
Store(
initial: CounterState(),
reduceIntent: { state, intent in
guard let effect = reduce(state: &state, intent: intent) else { return nil }
return .run {
await run(effect, service: service)
}
},
reduceAction: { state, action in
reduce(state: &state, action: action)
}
)
}
```
## Store Pattern
- Keep store on main actor for UI mutation safety.
- Receive `Intent`, run reducer, execute `Effect`, dispatch `Action`.
- Add cancellation and request versioning for concurrent requests.
- Map all expected service failures to explicit failure actions; `onUnexpectedError` should be a bug hook, not a business-error path.
```swift
@MainActor
final class Store<State, Intent, Action>: ObservableObject {
@Published private(set) var state: State
private let reduceIntent: (inout State, Intent) -> Effect<Action>?
private let reduceAction: (inout State, Action) -> Void
private let onUnexpectedError: @MainActor (Error) -> Void
private var activeTasks: [AnyHashable: Task<Void, Never>] = [:]
init(
initial: State,
reduceIntent: @escaping (inout State, Intent) -> Effect<Action>?,
reduceAction: @escaping (inout State, Action) -> Void,
onUnexpectedError: @escaping @MainActor (Error) -> Void = { error in
assertionFailure("Unexpected unmodeled effect error: \(error)")
}
) {
self.state = initial
self.reduceIntent = reduceIntent
self.reduceAction = reduceAction
self.onUnexpectedError = onUnexpectedError
}
func send(_ intent: Intent) {
guard let effect = reduceIntent(&state, intent) else { return }
handle(effect)
}
private func handle(_ effect: Effect<Action>) {
switch effect {
case .none:
break
case .run(let operation):
Task {
do {
let action = try await operation()
reduceAction(&state, action)
} catch is CancellationError {
// Task was cancelled; no state update.
} catch {
onUnexpectedError(error)
}
}
case .cancellable(let id, let operation):
activeTasks[id]?.cancel()
activeTasks[id] = Task {
do {
let action = try await operation()
reduceAction(&state, action)
} catch is CancellationError {
// Cancelled by a newer request for the same id.
} catch {
onUnexpectedError(error)
}
activeTasks[id] = nil
}
}
}
deinit {
for task in activeTasks.values { task.cancel() }
}
}
```
Map expected service failures to explicit failure actions; reserve `onUnexpectedError` for true fallthrough faults (for example decoding bugs, violated invariants, or effect wiring mistakes). If this handler fires for normal API failures, treat that as a modeling bug and add an explicit failure action path.
## Composed Reducers
Split reducers by feature and compose them.
```swift
enum AppAction {
case counter(CounterAction)
case settings(SettingsAction)
}
func appReduce(
state: inout AppState,
intent: AppIntent,
services: AppServices
) -> Effect<AppAction>? {
switch intent {
case .counter(let counterIntent):
return counterReduce(
state: &state.counter,
intent: counterIntent,
service: services.counter
)?.map(AppAction.counter)
case .settings(let settingsIntent):
return settingsReduce(
state: &state.settings,
intent: settingsIntent,
service: services.settings
)?.map(AppAction.settings)
}
}
```
Add a `map` helper on `Effect` to lift child actions into parent actions:
```swift
extension Effect {
func map<B>(_ transform: @escaping (Action) -> B) -> Effect<B> {
switch self {
case .none:
return .none
case .run(let operation):
return .run {
let action = try await operation()
return transform(action)
}
case .cancellable(let id, let operation):
return .cancellable(id: id) {
let action = try await operation()
return transform(action)
}
}
}
}
```
Composition tradeoff: a single app-wide `AppIntent`/`AppAction` can become deeply nested as feature count grows. Prefer feature-scoped stores where practical, and compose only at flow boundaries (for example tab root, checkout flow, onboarding) instead of forcing one global mega-enum.
## View Guidance
- Render `store.state` only.
- Send user events through `store.send(intent)`.
- Never mutate domain state directly in views.
### SwiftUI Integration
```swift
struct CounterView: View {
@StateObject var store: Store<CounterState, CounterIntent, CounterAction>
var body: some View {
VStack {
Text("Count: \(store.state.count)")
if case .loading = store.state.load { ProgressView() }
Button("+") { store.send(.incrementTapped) }
Button("-") { store.send(.decrementTapped) }
Button("Reset") { store.send(.resetTapped) }
}
}
}
```
If you target iOS 17+ for SwiftUI-first features, you can replace `ObservableObject`/`@Published` stores with `@Observable` stores (as in the MVVM playbook) and use `@State` + `@Bindable` in views. Keep `ObservableObject` when the same store must expose Combine publishers to UIKit.
### UIKit Integration
In UIKit, subscribe once, render from state, and map control events to intents.
```swift
import Combine
import UIKit
final class CounterViewController: UIViewController {
private let store: Store<CounterState, CounterIntent, CounterAction>
private var cancellables = Set<AnyCancellable>()
init(store: Store<CounterState, CounterIntent, CounterAction>) {
self.store = store
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { return nil }
override func viewDidLoad() {
super.viewDidLoad()
store.$state
.receive(on: RunLoop.main)
.sink { [weak self] in self?.render($0) }
.store(in: &cancellables)
}
@objc private func incrementTapped() {
store.send(.incrementTapped)
}
private func render(_ state: CounterState) {
title = "Count: \(state.count)"
// Update labels/buttons/loading from state only.
}
}
```
UIKit rules:
- keep all UI writes in `render(_:)`
- convert delegate/target-action callbacks into `Intent`
## Concurrency Rules
- Track active tasks by intent/effect key where duplicate requests are possible.
- Cancel stale in-flight work before starting a newer request.
- Use request IDs when responses can arrive out-of-order.
- Keep shared mutable service state in actors.
## Anti-Patterns and Fixes
1. Side effects inside reducer:
- Smell: analytics/network calls directly in reducer branch.
- Fix: emit `Effect` and handle through action loop.
2. Intent and action merged:
- Smell: one enum for both user input and effect output.
- Fix: separate `Intent` and `Action`.
3. Multiple sources of truth:
- Smell: local `@State` mirrors store state.
- Fix: keep canonical state in store only.
4. Derived fields stored redundantly:
- Smell: persisted `isEven` with `count`.
- Fix: compute derived properties.
5. Monolithic reducer:
- Smell: very large switch spanning unrelated domains.
- Fix: split reducers by feature and combine.
## Testing Expectations
- Unit test intent reducer transitions.
- Unit test action reducer success/failure transitions.
- Verify cancellation and stale-response handling.
- Keep tests deterministic with controlled services, schedulers, or clocks.
- Assert state-machine behavior, not view details.
Example test suite:
```swift
import XCTest
struct StubCounterService: CounterServicing {
func increment() async throws -> Int { 1 }
func decrement() async throws -> Int { 0 }
func reset() async throws -> Int { 0 }
}
final class CounterReducerTests: XCTestCase {
func test_intentIncrement_setsLoading_andReturnsEffect() {
var state = CounterState()
let service = StubCounterService()
let effect = reduce(
state: &state,
intent: .incrementTapped,
service: service
)
XCTAssertEqual(state.load, .loading)
XCTAssertNotNil(effect)
}
func test_actionFailure_setsError_andStopsLoading() {
var state = CounterState(load: .loaded(3))
reduce(state: &state, action: .incrementResponse(.failure(TestError.offline)))
XCTAssertEqual(state.count, 0)
if case .failed = state.load {
// expected
} else {
XCTFail("Expected failed state")
}
}
}
struct SearchState: Equatable {
var latestRequestID: UUID?
var results: [String] = []
}
enum SearchAction {
case response(requestID: UUID, Result<[String], Error>)
}
func reduce(state: inout SearchState, action: SearchAction) {
switch action {
case .response(let requestID, .success(let results)):
guard requestID == state.latestRequestID else { return }
state.results = results
case .response:
break
}
}
final class SearchReducerTests: XCTestCase {
func test_matchingLatestRequest_updatesResults() {
let requestID = UUID()
var state = SearchState(latestRequestID: requestID, results: [])
reduce(
state: &state,
action: .response(requestID: requestID, .success(["new"]))
)
XCTAssertEqual(state.results, ["new"])
}
func test_staleResponse_isIgnored() {
let latestID = UUID()
let staleID = UUID()
var state = SearchState(latestRequestID: latestID, results: ["current"])
reduce(
state: &state,
action: .response(requestID: staleID, .success(["old"]))
)
XCTAssertEqual(state.results, ["current"])
}
}
private enum TestError: Error {
case offline
}
```
## When to Prefer MVI
Prefer MVI for:
- complex state machines
- heavy concurrency/effect orchestration
- high determinism and testability requirements
Prefer MVVM when:
- screen complexity is moderate
- lower boilerplate is more important than strict state-machine modeling
## PR Review Checklist
- State is value-based and canonical.
- Reducers are deterministic and side-effect free.
- Effects are isolated and mapped back into actions.
- Cancellation/versioning exists for concurrent requests.
- View sends intents only; no direct business mutation.
- Reducer tests cover success, failure, and cancellation.

View File

@@ -0,0 +1,410 @@
# MVP Playbook (Swift + SwiftUI/UIKit)
Use this reference when you need a passive View that delegates all logic to a Presenter, especially in UIKit codebases where direct testability of presentation logic is a priority.
## Core Boundaries
- Model: Domain entities and business rules. No UI dependencies.
- View: Passive renderer driven entirely by Presenter commands. Owns no logic.
- Presenter: Owns all presentation logic, maps Model data to display output, and drives View updates through a protocol.
- Services/Repositories: Side-effect boundaries (network, persistence) injected into Presenter.
Dependency direction:
```text
View -> Presenter (user actions)
Presenter -> View (via ViewProtocol, one-way commands)
Presenter -> Repository/Service (via protocols)
```
The key difference from MVVM: the View holds no observable state — it passively executes commands dispatched by the Presenter.
## Feature Structure
```text
App/
Features/
Profile/
ProfileViewController.swift (View)
ProfilePresenter.swift
ProfileViewProtocol.swift
ProfileViewData.swift
ProfileAssembly.swift
Navigation/
AppCoordinator.swift
Domain/
Entities/
Repositories/
Data/
Repositories/
API/
```
## View Protocol
Define the View as a weak protocol. The Presenter drives state through it.
```swift
@MainActor
protocol ProfileView: AnyObject {
func showLoading(_ isLoading: Bool)
func show(profile: ProfileViewData)
func showError(message: String)
}
```
Rules:
- use `AnyObject` to allow weak references
- methods represent view commands, not state flags
- keep the protocol focused — one command per distinct UI concern
## View Data
Map domain entities to display-ready values in the Presenter, not the View.
```swift
struct ProfileViewData: Equatable {
let displayName: String
let badgeText: String?
let formattedJoinDate: String
}
```
## Presenter Pattern
Own task management, cancel stale work, and gate updates by request identity.
```swift
@MainActor
final class ProfilePresenter {
weak var view: ProfileView?
private let repository: ProfileRepository
private var loadTask: Task<Void, Never>?
private var latestRequestID: UUID?
init(repository: ProfileRepository) {
self.repository = repository
}
func viewDidAppear() {
load()
}
func load() {
let requestID = UUID()
latestRequestID = requestID
loadTask?.cancel()
view?.showLoading(true)
loadTask = Task {
do {
let user = try await repository.fetchCurrentUser()
try Task.checkCancellation()
guard latestRequestID == requestID else { return }
let viewData = ProfileViewData(user: user)
view?.show(profile: viewData)
} catch is CancellationError {
// Cancelled by a newer request do not update view.
} catch {
guard latestRequestID == requestID else { return }
view?.showError(message: "Failed to load profile. Please try again.")
}
guard latestRequestID == requestID else { return }
view?.showLoading(false)
}
}
deinit {
loadTask?.cancel()
}
}
extension ProfileViewData {
init(user: User) {
self.displayName = user.name
self.badgeText = user.isPremium ? "Premium" : nil
self.formattedJoinDate = user.joinDate.formatted(.dateTime.year().month())
}
}
```
Rules:
- `view` is `weak` to avoid retain cycles
- cancel in-flight task before starting a new one
- gate state updates by `requestID` to prevent stale overwrites
## UIKit View Implementation
The UIKit view controller forwards actions to Presenter and executes view commands.
```swift
@MainActor
final class ProfileViewController: UIViewController, ProfileView {
private let presenter: ProfilePresenter
private let nameLabel = UILabel()
private let activityIndicator = UIActivityIndicatorView(style: .medium)
private let errorLabel = UILabel()
init(presenter: ProfilePresenter) {
self.presenter = presenter
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
setupLayout()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
presenter.viewDidAppear()
}
// MARK: - ProfileView
func showLoading(_ isLoading: Bool) {
isLoading ? activityIndicator.startAnimating() : activityIndicator.stopAnimating()
}
func show(profile: ProfileViewData) {
nameLabel.text = profile.displayName
errorLabel.isHidden = true
}
func showError(message: String) {
errorLabel.text = message
errorLabel.isHidden = false
}
private func setupLayout() {
// Layout setup omitted for brevity.
}
}
```
## SwiftUI Adapter
For SwiftUI, bridge via a thin observable adapter that conforms to `ProfileView`.
```swift
@MainActor
@Observable
final class ProfileViewAdapter: ProfileView {
private(set) var viewData: ProfileViewData?
private(set) var isLoading = false
private(set) var errorMessage: String?
private let presenter: ProfilePresenter
init(presenter: ProfilePresenter) {
self.presenter = presenter
presenter.view = self
}
func showLoading(_ isLoading: Bool) {
self.isLoading = isLoading
}
func show(profile: ProfileViewData) {
self.viewData = profile
self.errorMessage = nil
}
func showError(message: String) {
self.errorMessage = message
}
func viewDidAppear() { presenter.viewDidAppear() }
}
struct ProfileScreen: View {
@State private var adapter: ProfileViewAdapter
init(adapter: ProfileViewAdapter) {
self._adapter = State(initialValue: adapter)
}
var body: some View {
Group {
if adapter.isLoading {
ProgressView()
} else if let viewData = adapter.viewData {
VStack(alignment: .leading, spacing: 8) {
Text(viewData.displayName).font(.title)
if let badge = viewData.badgeText {
Text(badge).font(.caption)
}
}
} else if let error = adapter.errorMessage {
Text(error).foregroundStyle(.red)
}
}
.onAppear { adapter.viewDidAppear() }
}
}
```
## Assembly
Wire dependencies in one place — the assembler or coordinator.
```swift
enum ProfileAssembly {
static func build(repository: ProfileRepository) -> UIViewController {
let presenter = ProfilePresenter(repository: repository)
let viewController = ProfileViewController(presenter: presenter)
presenter.view = viewController
return viewController
}
@MainActor
static func buildSwiftUI(repository: ProfileRepository) -> ProfileScreen {
let presenter = ProfilePresenter(repository: repository)
let adapter = ProfileViewAdapter(presenter: presenter)
return ProfileScreen(adapter: adapter)
}
}
```
Rules:
- set `presenter.view` after construction, not inside the Presenter initializer
- inject concrete repositories from the composition root
- keep the assembly function as the only place that creates the full module
## Anti-Patterns and Fixes
1. View containing logic:
- Smell: UIViewController computes display strings, formats dates, or makes service calls.
- Fix: move all logic to Presenter; View receives ready-to-render view data.
2. Presenter observing state objects (ViewModel pattern leaking in):
- Smell: Presenter publishes `@Published` properties that the View observes directly.
- Fix: keep the Presenter command-driven; View state is driven by protocol method calls, not KVO or Combine pipelines.
3. Bidirectional strong references:
- Smell: Presenter holds a strong reference to View.
- Fix: declare `weak var view: ProfileView?` in Presenter.
4. No request-identity guard:
- Smell: rapid re-loads overwrite each other because any in-flight completion can update the View.
- Fix: assign a `UUID` per request and guard all view updates behind identity equality.
5. Fat Presenter:
- Smell: Presenter contains network code, caching logic, or routing details.
- Fix: delegate network and persistence to injected Repository protocols; delegate navigation to an injected Router or Coordinator.
## Testing Strategy
Test the Presenter in isolation with a mock View and stub Repository.
Verify the Presenter-to-View contract for success, failure, and cancellation paths.
Keep tests deterministic by controlling async behaviour with stubs, not `sleep`.
```swift
@MainActor
final class MockProfileView: ProfileView {
var isLoading = false
var shownViewData: ProfileViewData?
var shownError: String?
func showLoading(_ isLoading: Bool) { self.isLoading = isLoading }
func show(profile: ProfileViewData) { shownViewData = profile }
func showError(message: String) { shownError = message }
}
struct StubProfileRepository: ProfileRepository {
var result: Result<User, Error>
func fetchCurrentUser() async throws -> User { try result.get() }
}
@MainActor
final class ProfilePresenterTests: XCTestCase {
func test_load_success_showsUserName() async {
let user = User(id: UUID(), name: "Alice", isPremium: false, joinDate: .now)
let view = MockProfileView()
let presenter = ProfilePresenter(
repository: StubProfileRepository(result: .success(user))
)
presenter.view = view
presenter.load()
await Task.yield()
XCTAssertEqual(view.shownViewData?.displayName, "Alice")
XCTAssertNil(view.shownError)
}
func test_load_failure_showsError() async {
let view = MockProfileView()
let presenter = ProfilePresenter(
repository: StubProfileRepository(result: .failure(TestError.notFound))
)
presenter.view = view
presenter.load()
await Task.yield()
XCTAssertNotNil(view.shownError)
XCTAssertNil(view.shownViewData)
}
func test_load_cancellation_doesNotOverwriteExistingViewData() async {
let existing = User(id: UUID(), name: "Existing", isPremium: false, joinDate: .now)
let view = MockProfileView()
view.show(profile: ProfileViewData(user: existing))
let presenter = ProfilePresenter(
repository: StubProfileRepository(result: .failure(CancellationError()))
)
presenter.view = view
presenter.load()
await Task.yield()
XCTAssertEqual(view.shownViewData?.displayName, "Existing")
}
func test_rapidLoads_onlyLatestResultShown() async {
let firstUser = User(id: UUID(), name: "First", isPremium: false, joinDate: .now)
let view = MockProfileView()
let presenter = ProfilePresenter(
repository: StubProfileRepository(result: .success(firstUser))
)
presenter.view = view
// Simulate two rapid loads; second call cancels first.
presenter.load() // request A will be cancelled
presenter.load() // request B latest
await Task.yield()
await Task.yield()
XCTAssertEqual(view.shownViewData?.displayName, "First")
}
}
private enum TestError: Error { case notFound }
```
## When to Prefer MVP
Prefer MVP when:
- UIKit is the primary stack and you want full Presenter testability without observable state objects
- the View must be completely passive (no `if` logic, no `guard`, no formatting)
- migrating from MVC and want a minimal step up without pulling in Combine or the `@Observable` macro
- existing team is familiar with the Presenter + View protocol pattern
Prefer MVVM when:
- SwiftUI is the primary stack and `@Observable` / `@Published` state binding reduces wiring overhead
- you want reactive data flow with less hand-written command dispatch
Compared with VIPER, MVP omits the Interactor and Router as distinct components, making it lighter and simpler for single-screen features.
## PR Review Checklist
- View contains no business logic, data formatting, or service calls.
- `view` property in Presenter is `weak` and typed as `ProfileView`.
- Presenter cancels the previous task before starting a new load.
- All Presenter-to-View calls are guarded by request identity where async.
- Repository and service dependencies are injected via protocols, not singletons.
- Tests cover success, failure, and stale-cancellation paths.
- Assembly function wires the module from the outside — Presenter does not create its own dependencies.

View File

@@ -0,0 +1,769 @@
# MVVM Playbook (Swift + SwiftUI/UIKit)
Use this reference for MVVM requests or screen-level state with async effects.
## Core Boundaries
- Model: Domain entities and business rules. Keep UI-framework independent.
- View: Render state and forward user intents. Do not call services directly.
- ViewModel: Own presentation state, map domain to view data, coordinate effects.
- Services/Repositories: Side-effect boundaries (network, persistence, analytics).
Dependency direction:
- View -> ViewModel
- ViewModel -> UseCases/Repositories/Services (via protocols)
- Model -> no dependency on View/ViewModel
## Feature Structure
Prefer vertical feature slices with clear boundaries. Treat this layout as illustrative, not a required file checklist for every feature:
```text
App/
Features/
Feed/
FeedView.swift
FeedViewModel.swift
FeedState.swift
FeedViewData.swift
FeedDestination.swift
FeedAssembly.swift
Navigation/
AppRouter.swift
DeepLink.swift
Domain/
Entities/
UseCases/
Data/
Repositories/
API/
Persistence/
```
## State Modeling
Use explicit state types over boolean combinations.
```swift
enum Loadable<Value: Equatable>: Equatable {
case idle
case loading
case loaded(Value)
case failed(String)
}
struct FeedItemViewData: Identifiable, Hashable {
let id: UUID
let title: String
}
struct ToastState: Equatable {
let message: String
}
struct FeedState: Equatable {
var load: Loadable<Void> = .idle
var items: [FeedItemViewData] = []
var isRefreshing = false
var toast: ToastState?
}
```
## ViewModel Pattern
Keep mutation on main actor, own task handles, and cancel stale work.
### Modern Pattern (iOS 17+ / `@Observable`)
```swift
@MainActor
@Observable
final class FeedViewModel {
private(set) var state = FeedState()
private let repository: FeedRepository
private var loadTask: Task<Void, Never>?
init(repository: FeedRepository) {
self.repository = repository
}
func onAppear() {
guard case .idle = state.load else { return }
load()
}
func load() {
loadTask?.cancel()
state.load = .loading
loadTask = Task {
do {
let page = try await repository.fetchPage(cursor: nil)
try Task.checkCancellation()
state.items = page.items.map(FeedItemViewData.init)
state.load = .loaded(())
} catch is CancellationError {
// Ignore cancellation.
} catch {
state.load = .failed(error.localizedDescription)
}
}
}
deinit {
loadTask?.cancel()
}
}
```
### Legacy Pattern (iOS 16 and earlier / `ObservableObject`)
```swift
@MainActor
final class FeedViewModel: ObservableObject {
@Published private(set) var state = FeedState()
private let repository: FeedRepository
private var loadTask: Task<Void, Never>?
init(repository: FeedRepository) {
self.repository = repository
}
func onAppear() {
guard case .idle = state.load else { return }
load()
}
func load() {
loadTask?.cancel()
state.load = .loading
loadTask = Task {
do {
let page = try await repository.fetchPage(cursor: nil)
try Task.checkCancellation()
state.items = page.items.map(FeedItemViewData.init)
state.load = .loaded(())
} catch is CancellationError {
// Ignore cancellation.
} catch {
state.load = .failed(error.localizedDescription)
}
}
}
deinit {
loadTask?.cancel()
}
}
```
## Dependency Injection
Inject abstractions into ViewModel constructors. Build live dependencies in feature assembly.
```swift
protocol FeedRepository {
func fetchPage(cursor: String?) async throws -> FeedPage
}
enum FeedAssembly {
static func makeViewModel() -> FeedViewModel {
FeedViewModel(repository: LiveFeedRepository(api: .live))
}
}
```
`FeedAssembly.makeViewModel()` keeps feature wiring obvious, but can become limiting as apps grow. A common evolution path is an app-level dependency container (composition root) that owns shared dependency graphs.
```swift
protocol AppDependencies {
var feedRepository: FeedRepository { get }
}
struct LiveDependencies: AppDependencies {
private let api: APIClient
init(api: APIClient) {
self.api = api
}
var feedRepository: FeedRepository {
LiveFeedRepository(api: api)
}
}
@MainActor
final class AppContainer {
private let dependencies: AppDependencies
init(dependencies: AppDependencies) {
self.dependencies = dependencies
}
func makeFeedViewModel() -> FeedViewModel {
FeedViewModel(repository: dependencies.feedRepository)
}
}
```
## View Guidance
- Bind to ViewModel state only.
- Keep business transforms out of `body`/`cellForRowAt`.
- Expose dedicated `ViewData` structs for formatting and display concerns.
- Keep View-local state only for transient UI details (focus, scroll position).
SwiftUI view with `@Observable` ViewModel (iOS 17+):
```swift
struct FeedView: View {
@State private var viewModel: FeedViewModel
init(viewModel: FeedViewModel) {
_viewModel = State(wrappedValue: viewModel)
}
var body: some View {
List(viewModel.state.items, id: \.id) { item in
Text(item.title)
}
.task { viewModel.onAppear() }
}
}
```
SwiftUI view with `ObservableObject` ViewModel (iOS 16 and earlier):
```swift
struct FeedView: View {
@StateObject private var viewModel: FeedViewModel
init(viewModel: FeedViewModel) {
_viewModel = StateObject(wrappedValue: viewModel)
}
var body: some View {
List(viewModel.state.items, id: \.id) { item in
Text(item.title)
}
.task { viewModel.onAppear() }
}
}
```
## Navigation Patterns
Keep routing decisions testable and decoupled from presentation APIs: ViewModel decides *where*, routing layer decides *how*.
### SwiftUI Navigation (iOS 16+ / `NavigationStack`)
Model destinations as an enum. Prefer stable IDs over list-specific `ViewData`.
Path ownership is a real tradeoff:
- ViewModel-owned path: simplest end-to-end SwiftUI wiring, but mixes data/loading state with navigation state.
- View-owned path: keeps ViewModel state focused on data/loading, but requires an intent API so route decisions stay testable.
- Router-owned path: best for multi-screen flows and deep links, with extra types/wiring cost.
The examples below show ViewModel-owned and router-owned patterns.
```swift
enum FeedDestination: Hashable {
case detail(id: UUID)
case profile(userId: UUID)
case settings
}
```
Option A: ViewModel-owned path.
```swift
@MainActor
@Observable
final class FeedViewModel {
private(set) var state = FeedState()
var navigationPath: [FeedDestination] = []
// ...existing properties...
func didTapItem(_ item: FeedItemViewData) {
navigationPath.append(.detail(id: item.id))
}
func didTapProfile(userId: UUID) {
navigationPath.append(.profile(userId: userId))
}
}
```
View binds the path to `NavigationStack`:
```swift
struct FeedView: View {
@State private var viewModel: FeedViewModel
var body: some View {
@Bindable var viewModel = viewModel
NavigationStack(path: $viewModel.navigationPath) {
List(viewModel.state.items) { item in
Button(item.title) {
viewModel.didTapItem(item)
}
}
.navigationDestination(for: FeedDestination.self) { destination in
switch destination {
case .detail(let itemID):
FeedDetailView(viewModel: FeedDetailViewModel(itemID: itemID))
case .profile(let userId):
ProfileView(viewModel: ProfileViewModel(userId: userId))
case .settings:
SettingsView(viewModel: SettingsViewModel())
}
}
.task { viewModel.onAppear() }
}
}
}
```
Option B: dedicated router keeps `FeedState` focused on presentation data/loading.
```swift
@MainActor
@Observable
final class FeedRouter {
var path: [FeedDestination] = []
func push(_ destination: FeedDestination) {
path.append(destination)
}
}
@MainActor
@Observable
final class FeedViewModel {
private(set) var state = FeedState()
func destinationForItem(_ item: FeedItemViewData) -> FeedDestination {
.detail(id: item.id)
}
}
struct FeedView: View {
@State private var viewModel: FeedViewModel
@State private var router = FeedRouter()
var body: some View {
@Bindable var router = router
NavigationStack(path: $router.path) {
List(viewModel.state.items) { item in
Button(item.title) {
router.push(viewModel.destinationForItem(item))
}
}
}
}
}
```
### Modal / Sheet Presentation
Model sheet presentation as optional state on the ViewModel.
```swift
@MainActor
@Observable
final class FeedViewModel {
private(set) var state = FeedState()
var activeSheet: FeedSheet?
struct FeedFilter: Equatable {
var showUnreadOnly = false
}
enum FeedSheet: Identifiable {
case compose
case filter(current: FeedFilter)
var id: String {
switch self {
case .compose: "compose"
case .filter: "filter"
}
}
}
func didTapCompose() {
activeSheet = .compose
}
}
```
```swift
struct FeedView: View {
@State private var viewModel: FeedViewModel
var body: some View {
@Bindable var viewModel = viewModel
List(viewModel.state.items) { item in
Text(item.title)
}
.sheet(item: $viewModel.activeSheet) { sheet in
switch sheet {
case .compose:
ComposeView(viewModel: ComposeViewModel())
case .filter(let current):
FilterView(viewModel: FilterViewModel(filter: current))
}
}
}
}
```
### Coordinator Pattern (UIKit or Mixed Codebases)
When UIKit is involved or complex multi-step flows require centralized control, use a Coordinator protocol.
```swift
@MainActor
protocol FeedCoordinator: AnyObject {
func showDetail(itemID: UUID)
func showProfile(userId: UUID)
func presentCompose(onComplete: @MainActor @escaping () -> Void)
}
```
Inject the Coordinator into the ViewModel:
```swift
@MainActor
@Observable
final class FeedViewModel {
private(set) var state = FeedState()
private let repository: FeedRepository
private weak var coordinator: FeedCoordinator?
private var loadTask: Task<Void, Never>?
init(repository: FeedRepository, coordinator: FeedCoordinator) {
self.repository = repository
self.coordinator = coordinator
}
func didTapItem(_ item: FeedItemViewData) {
coordinator?.showDetail(itemID: item.id)
}
func didTapCompose() {
coordinator?.presentCompose { [weak self] in
self?.load()
}
}
}
```
Concrete implementation lives in the navigation layer:
```swift
@MainActor
final class FeedFlowCoordinator: FeedCoordinator {
private let navigationController: UINavigationController
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func showDetail(itemID: UUID) {
let viewModel = FeedDetailAssembly.makeViewModel(itemID: itemID)
let vc = UIHostingController(rootView: FeedDetailView(viewModel: viewModel))
navigationController.pushViewController(vc, animated: true)
}
func showProfile(userId: UUID) {
let viewModel = ProfileAssembly.makeViewModel(userId: userId)
let vc = UIHostingController(rootView: ProfileView(viewModel: viewModel))
navigationController.pushViewController(vc, animated: true)
}
func presentCompose(onComplete: @MainActor @escaping () -> Void) {
let composeVM = ComposeAssembly.makeViewModel(onComplete: onComplete)
let vc = UIHostingController(rootView: ComposeView(viewModel: composeVM))
navigationController.present(vc, animated: true)
}
}
```
### Deep Linking
Centralize deep link resolution in a router that maps URLs to navigation destinations.
```swift
enum DeepLink {
case feedItem(id: UUID)
case profile(userId: UUID)
case settings
init?(url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let host = components.host else { return nil }
switch host {
case "feed":
guard let idString = components.queryItems?.first(where: { $0.name == "id" })?.value,
let id = UUID(uuidString: idString) else { return nil }
self = .feedItem(id: id)
case "profile":
guard let idString = components.queryItems?.first(where: { $0.name == "userId" })?.value,
let id = UUID(uuidString: idString) else { return nil }
self = .profile(userId: id)
case "settings":
self = .settings
default:
return nil
}
}
}
```
Apply deep links to existing navigation state:
```swift
@MainActor
@Observable
final class AppRouter {
var feedViewModel: FeedViewModel
func handle(_ deepLink: DeepLink) {
switch deepLink {
case .feedItem(let id):
feedViewModel.navigationPath = [.detail(id: id)]
case .profile(let userId):
feedViewModel.navigationPath = [.profile(userId: userId)]
case .settings:
feedViewModel.navigationPath = [.settings]
}
}
}
```
### Which Pattern to Choose
| Scenario | Recommended Pattern |
|---|---|
| Pure SwiftUI, linear flows | `NavigationStack` path on ViewModel |
| Sheets, alerts, confirmations | Optional state-driven presentation |
| UIKit host or mixed SwiftUI/UIKit | Coordinator protocol |
| Multi-step flows (onboarding, checkout) | Coordinator with child coordinators |
| Universal Links / push notifications | Deep link router + state-driven nav |
## Anti-Patterns and Fixes
1. God ViewModel:
- Smell: networking, parsing, persistence, and state orchestration all in one class.
- Fix: extract UseCases/Repositories; keep ViewModel focused on state and intent handling.
2. Duplicate state in View and ViewModel:
- Smell: `@State var items` and `viewModel.state.items` coexist.
- Fix: one source of truth in ViewModel.
3. Stale async overwrite:
- Smell: older response replaces newer state.
- Fix: cancel in-flight task before new request and check cancellation.
4. Navigation logic inside ViewModel with UIKit types:
- Smell: direct `UINavigationController` usage in ViewModel.
- Fix: inject Router/Coordinator protocol.
5. Heavy work on main actor:
- Smell: decoding or expensive mapping in main-actor methods.
- Fix: move heavy CPU work off-main; assign final state on main actor.
```swift
// Anti-pattern: expensive mapping runs on @MainActor.
@MainActor
func load() {
loadTask?.cancel()
state.load = .loading
loadTask = Task {
do {
let page = try await repository.fetchPage(cursor: nil)
state.items = page.items.map(FeedItemViewData.init) // can hitch UI for large pages
state.load = .loaded(())
} catch is CancellationError {
// Ignore cancellation.
} catch {
state.load = .failed(error.localizedDescription)
}
}
}
// Better: do CPU-heavy mapping off actor, then commit state on @MainActor.
@MainActor
func load() {
loadTask?.cancel()
state.load = .loading
loadTask = Task {
do {
let page = try await repository.fetchPage(cursor: nil)
let mappedItems = try await Task.detached(priority: .userInitiated) {
page.items.map(FeedItemViewData.init)
}.value
try Task.checkCancellation()
state.items = mappedItems
state.load = .loaded(())
} catch is CancellationError {
// Ignore cancellation.
} catch {
state.load = .failed(error.localizedDescription)
}
}
}
```
If mapping is small but reused, extract it into a pure helper (`static`/`nonisolated`) for testability; if it is expensive, run it off actor (`Task.detached` or a background service). Under strict concurrency (Swift 6), ensure detached-task captures/results are `Sendable`, or move the work behind a background actor/service boundary.
## Testing Expectations
Focus on deterministic state transitions:
- success path (`loading -> loaded`)
- failure path (`loading -> failed`)
- cancellation path (no stale overwrite)
- mapping correctness (domain -> view data)
Test strategy:
- Use protocol stubs/fakes for repositories.
- Avoid sleep-based tests; use controllable stub responses.
- If ViewModel is `@MainActor`, run assertions through `await MainActor.run`.
```swift
import XCTest
struct FeedItem: Equatable {
let id: UUID
let title: String
}
struct FeedPage: Equatable {
let items: [FeedItem]
}
extension FeedItemViewData {
init(_ item: FeedItem) {
self.id = item.id
self.title = item.title
}
}
actor ControlledFeedRepository: FeedRepository {
private var continuations: [CheckedContinuation<FeedPage, Error>] = []
func fetchPage(cursor: String?) async throws -> FeedPage {
try await withCheckedThrowingContinuation { continuation in
continuations.append(continuation)
}
}
func resolveNext(with result: Result<FeedPage, Error>) {
guard !continuations.isEmpty else { return }
let continuation = continuations.removeFirst()
switch result {
case .success(let page):
continuation.resume(returning: page)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
@MainActor
final class FeedViewModelTests: XCTestCase {
func test_load_success_setsLoadedAndMapsItems() async {
let repository = ControlledFeedRepository()
let sut = FeedViewModel(repository: repository)
let expected = FeedPage(items: [FeedItem(id: UUID(), title: "A")])
sut.load()
await repository.resolveNext(with: .success(expected))
await Task.yield()
XCTAssertEqual(sut.state.items.map(\.title), ["A"])
if case .loaded = sut.state.load {
// expected
} else {
XCTFail("Expected loaded state")
}
}
func test_load_failure_setsFailed() async {
let repository = ControlledFeedRepository()
let sut = FeedViewModel(repository: repository)
sut.load()
await repository.resolveNext(with: .failure(TestError.offline))
await Task.yield()
if case .failed = sut.state.load {
// expected
} else {
XCTFail("Expected failed state")
}
}
func test_load_cancellation_ignoresStaleResult() async {
let repository = ControlledFeedRepository()
let sut = FeedViewModel(repository: repository)
let stale = FeedPage(items: [FeedItem(id: UUID(), title: "stale")])
let latest = FeedPage(items: [FeedItem(id: UUID(), title: "latest")])
sut.load() // request A
sut.load() // request B cancels A
await repository.resolveNext(with: .success(stale))
await repository.resolveNext(with: .success(latest))
await Task.yield()
await Task.yield()
XCTAssertEqual(sut.state.items.map(\.title), ["latest"])
}
}
private enum TestError: Error {
case offline
}
```
## When to Prefer MVVM
Prefer MVVM when:
- screen-level state management is the primary concern
- team wants explicit View/ViewModel boundaries without introducing a full reducer/store framework
- feature complexity is moderate and does not require strict unidirectional flow
- the team accepts moderate structure (for example, `State`, `ViewData`, assembly/router types) in exchange for clarity and testability
MVVM is often lower ceremony than TCA/VIPER, but not "no ceremony." A strict MVVM style can introduce several files per feature; scale file splitting to actual complexity instead of applying every type up front.
Prefer MVI/TCA when:
- deterministic state-machine modeling is required
- complex effect orchestration and cancellation correctness are critical
Prefer Clean Architecture/VIPER when:
- strict layer boundaries and use-case isolation matter more than presentation-layer simplicity
## PR Review Checklist
- View does not call services directly.
- ViewModel exposes explicit state model.
- Dependencies are injected (no app-wide singleton dependency in ViewModel).
- Async tasks have cancellation strategy.
- Domain models are not directly coupled to View rendering.
- Navigation destinations are modeled as value types (enum/struct), not imperative calls.
- ViewModel does not import UIKit or reference presentation APIs directly.
- Deep link handling routes through a centralized router, not ad-hoc view logic.
- Unit tests cover success, failure, and cancellation.

View File

@@ -0,0 +1,327 @@
# Reactive Architecture Playbook (Swift + Combine/RxSwift)
Use this reference for stream-driven features (search, live updates, real-time feeds).
## Core Philosophy
Model inputs, transforms, and outputs as streams.
```text
Input -> Publisher/Observable chain -> State -> UI
```
Keep stream composition in presentation or a dedicated reactive layer, not in views.
## Canonical Combine Pattern
```swift
final class SearchViewModel<S: Scheduler>: ObservableObject
where S.SchedulerTimeType == DispatchQueue.SchedulerTimeType {
@Published var query = ""
@Published private(set) var results: [String] = []
private var cancellables = Set<AnyCancellable>()
init(service: SearchService, scheduler: S) {
$query
.debounce(for: .milliseconds(300), scheduler: scheduler)
.removeDuplicates()
.map { query in
service.search(query)
.replaceError(with: [])
}
.switchToLatest()
.receive(on: scheduler)
.sink { [weak self] values in
self?.results = values
}
.store(in: &cancellables)
}
}
```
In production, pass `DispatchQueue.main` as the scheduler.
Rules:
- debounce user text input
- remove duplicates where meaningful
- hop to main thread before UI-bound state writes
- keep cancellables tied to lifecycle
## UI Integration by Stack
### SwiftUI Pattern
- Keep operator chains in `ObservableObject`/`@Observable` types, not in `View`.
- Bind UI input (`TextField`, toggle, selection) to published inputs on the model.
### UIKit Pattern (Combine)
- Keep pipelines in Presenter/ViewModel.
- Map delegate/target-action callbacks into input subjects.
- Render from a single state subscription.
```swift
import Combine
import UIKit
@MainActor
final class SearchPresenter<S: Scheduler> where S.SchedulerTimeType == DispatchQueue.SchedulerTimeType {
let state = CurrentValueSubject<SearchResultState, Never>(.loaded([]))
private let query = PassthroughSubject<String, Never>()
private var cancellables = Set<AnyCancellable>()
init(service: SearchService, scheduler: S) {
query
.debounce(for: .milliseconds(300), scheduler: scheduler)
.removeDuplicates()
.map { value in
service.search(value)
.map(SearchResultState.loaded)
.catch { Just(.failed($0.localizedDescription)) }
}
.switchToLatest()
.sink { [weak self] in self?.state.send($0) }
.store(in: &cancellables)
}
func queryChanged(_ text: String) { query.send(text) }
}
// In production, pass DispatchQueue.main as the scheduler.
final class SearchViewController: UIViewController, UISearchBarDelegate {
private let presenter: SearchPresenter<DispatchQueue>
private var cancellables = Set<AnyCancellable>()
init(presenter: SearchPresenter<DispatchQueue>) {
self.presenter = presenter
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { return nil }
override func viewDidLoad() {
super.viewDidLoad()
presenter.state
.sink { [weak self] in self?.render($0) }
.store(in: &cancellables)
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
presenter.queryChanged(searchText)
}
private func render(_ state: SearchResultState) {
// Render labels/list/error from stream state.
}
}
```
## Operator Guidance
- `debounce`: stabilize noisy user input (search fields)
- `throttle`: limit high-frequency events (scroll, sensor)
- `flatMap`: merge concurrent async work when all responses matter
- `switchToLatest`: keep only newest request (typeahead/search)
- `share`: avoid duplicate side effects for multiple subscribers
- `catch`: recover from recoverable errors with fallback streams
Prefer `switchToLatest` over nested subscriptions for request replacement flows.
## RxSwift Mapping Notes
Combine and RxSwift mapping:
- `AnyPublisher` <-> `Observable`
- `AnyCancellable` <-> `DisposeBag`
- `receive(on:)` <-> `observe(on:)`
- `subscribe(on:)` semantics should be applied intentionally to offload heavy work
## Error Handling Pattern
Recover in stream boundaries and expose user-safe state:
```swift
protocol SearchService {
func search(_ query: String) -> AnyPublisher<[String], Error>
}
enum SearchResultState: Equatable {
case loaded([String])
case failed(String)
}
func searchState(
query: String,
service: SearchService
) -> AnyPublisher<SearchResultState, Never> {
service.search(query)
.map(SearchResultState.loaded)
.catch { Just(.failed($0.localizedDescription)) }
.eraseToAnyPublisher()
}
```
For transient failures, prefer fallback state over terminating the stream.
## Anti-Patterns and Fixes
1. Nested subscriptions:
- Smell: subscribe inside subscribe, difficult cancellation and reasoning.
- Fix: compose with `flatMap`/`switchToLatest`.
2. Missing cancellation/disposal:
- Smell: stream continues after screen deallocation or rebind.
- Fix: store `AnyCancellable` or use `DisposeBag` lifecycle correctly.
3. Business logic in view:
- Smell: view constructs pipelines and calls services directly.
- Fix: move stream orchestration to Presenter/ViewModel layer.
4. UI thread violations:
- Smell: publishing UI-bound state off main thread.
- Fix: apply `receive(on:)` / `observe(on:)` before UI mutations.
5. Unbounded fan-out:
- Smell: many subscribers trigger duplicate network calls.
- Fix: use `share`/multicasting where side effects should be single-execution.
## Testing Strategy
Test stream behavior deterministically:
- input -> expected output transitions
- success path emits the expected state sequence
- debounce/throttle behavior with controlled schedulers
- cancellation behavior for replaced requests
- error fallback behavior
Rules:
- inject schedulers/time providers for tests
- avoid real-time sleeps when possible
- assert emitted state sequence, not internal operator details
```swift
import Combine
import CombineSchedulers
import XCTest
final class SearchViewModelTests: XCTestCase {
func test_queryEmitsResults() {
let subject = PassthroughSubject<[String], Error>()
let stubService = StubSearchService { _ in subject.eraseToAnyPublisher() }
// Requires Point-Free's CombineSchedulers package.
let scheduler = DispatchQueue.test
let vm = SearchViewModel(service: stubService, scheduler: scheduler.eraseToAnyScheduler())
var collected: [[String]] = []
let cancellable = vm.$results
.dropFirst()
.sink { collected.append($0) }
vm.query = "swift"
// Advance past debounce interval.
scheduler.advance(by: .milliseconds(300))
// Simulate service response.
subject.send(["SwiftUI", "Swift"])
subject.send(completion: .finished)
// Advance to process receive(on:).
scheduler.advance()
XCTAssertEqual(collected, [["SwiftUI", "Swift"]])
cancellable.cancel()
}
func test_errorFallsBackToEmptyResults() {
let subject = PassthroughSubject<[String], Error>()
let stubService = StubSearchService { _ in subject.eraseToAnyPublisher() }
let scheduler = DispatchQueue.test
let vm = SearchViewModel(service: stubService, scheduler: scheduler.eraseToAnyScheduler())
var collected: [[String]] = []
let cancellable = vm.$results
.dropFirst()
.sink { collected.append($0) }
vm.query = "swift"
scheduler.advance(by: .milliseconds(300))
subject.send(completion: .failure(TestError.offline))
scheduler.advance()
XCTAssertEqual(collected, [[]])
cancellable.cancel()
}
func test_switchToLatest_ignoresStaleInFlightResponse() {
let first = PassthroughSubject<[String], Error>()
let second = PassthroughSubject<[String], Error>()
let stubService = StubSearchService { query in
switch query {
case "sw":
return first.eraseToAnyPublisher()
case "swift":
return second.eraseToAnyPublisher()
default:
return Empty<[String], Error>().eraseToAnyPublisher()
}
}
let scheduler = DispatchQueue.test
let vm = SearchViewModel(service: stubService, scheduler: scheduler.eraseToAnyScheduler())
var collected: [[String]] = []
let cancellable = vm.$results
.dropFirst()
.sink { collected.append($0) }
vm.query = "sw"
scheduler.advance(by: .milliseconds(300))
vm.query = "swift"
scheduler.advance(by: .milliseconds(300))
// This should be ignored because a newer query replaced the subscription.
first.send(["stale"])
second.send(["fresh"])
scheduler.advance()
XCTAssertEqual(collected, [["fresh"]])
cancellable.cancel()
}
}
struct StubSearchService: SearchService {
let searchHandler: (String) -> AnyPublisher<[String], Error>
func search(_ query: String) -> AnyPublisher<[String], Error> {
searchHandler(query)
}
}
private enum TestError: Error {
case offline
}
```
The canonical `SearchViewModel` already supports scheduler injection for tests.
## When to Prefer Reactive Architecture
Prefer when:
- feature is event-heavy and stream-oriented
- real-time updates and transformations are core behavior
- composable async pipelines provide clarity over imperative callbacks
Prefer MVI/TCA when:
- explicit state-machine and strict reducer flow are primary requirements
## PR Review Checklist
- Streams are composed without nested subscriptions.
- Cancellation/disposal is lifecycle-safe.
- UI-bound updates are marshaled to main thread.
- Operators match intent (`debounce`, `throttle`, `switchToLatest`, `share`).
- Views/controllers do not hold business pipeline logic.
- Error handling keeps UX resilient for transient failures.

View File

@@ -0,0 +1,141 @@
# Architecture Selection Guide
Use this reference when the user asks for an architecture recommendation.
## Decision Matrix
| Factor | MVVM | MVI | TCA | Clean | VIPER | Reactive | MVP | Coordinator |
|--------|------|-----|-----|-------|-------|----------|-----|-------------|
| State complexity | LowMed | High | High | MedHigh | Med | Med | LowMed | N/A (navigation layer) |
| Unidirectional flow | Optional | Strict | Strict | N/A | N/A | Stream-based | Optional | N/A |
| Composition / modularity | Feature-level | Feature-level | Strong (Scope/forEach) | Layer-level | Module-level | Operator-level | Feature-level | Flow-level |
| Testing determinism | Good | Very high | Very high (TestStore) | Good | Good | Good (with schedulers) | Good | Good |
| Boilerplate | Low | Medium | MediumHigh | MediumHigh | High | LowMedium | Medium | LowMedium |
| SwiftUI fit | Excellent | Good | Excellent | Good | Fair (UIKit-native) | Good | Fair | Good |
| UIKit fit | Good | Good | Good | Good | Excellent | Good | Excellent | Excellent |
| Team learning curve | Low | Medium | High | Medium | MediumHigh | Medium | Low | Low |
| Async/effect orchestration | Manual | Structured | Built-in | Manual | Manual | Operator-driven | Manual | N/A |
| Framework dependency | None | None | swift-composable-architecture | None | None | Combine or RxSwift | None | None |
## UI Stack Nuance by Architecture
- **MVVM**: SwiftUI favors direct state binding; UIKit/mixed favors coordinator-driven navigation.
- **MVI**: SwiftUI uses store-bound views; UIKit maps events to intents and renders from store state.
- **TCA**: SwiftUI uses `StoreOf` in views; UIKit uses a controller render loop from `ViewStore`.
- **Clean Architecture**: Domain/data stay the same; only presentation adapters differ.
- **VIPER**: UIKit-native fit; SwiftUI usually uses an adapter plus `UIHostingController`.
- **Reactive**: SwiftUI keeps pipelines in observable models; UIKit keeps them in Presenter/ViewModel.
- **MVP**: UIKit-native fit; Presenter drives passive View via protocol commands; SwiftUI uses an observable adapter.
- **Coordinator**: Works with both stacks; UIKit uses `UINavigationController` wrapper; SwiftUI models navigation as value-type state bound to `NavigationStack`.
## Quick Decision Flow
```text
1. Is the feature stream-heavy (search, live feeds, real-time updates)?
YES -> Consider Reactive (references/reactive.md). If strict reducer/state-machine flow is also required, continue to step 2 and likely combine patterns.
NO -> Continue
2. Is strict unidirectional data flow and state-machine modeling required?
YES -> Is the app already TCA-based, or is adding TCA dependency acceptable?
YES -> TCA (references/tca.md)
NO -> MVI (references/mvi.md)
NO -> Continue
3. Does the codebase need strict layer isolation with replaceable infrastructure?
YES -> Clean Architecture (references/clean-architecture.md)
NO -> Continue
4. Is this a large UIKit codebase needing strict per-feature separation?
YES -> VIPER (references/viper.md)
NO -> Continue
5. Is the primary goal decoupling navigation from screens (deep linking, reusable flows)?
YES -> Coordinator (references/coordinator.md) — pair with a presentation pattern below
NO -> Continue
6. Is UIKit the primary stack and a fully passive View with zero logic desired?
YES -> MVP (references/mvp.md)
NO -> Continue
7. Default recommendation:
-> MVVM (references/mvvm.md)
```
## Inference from User Constraints
Use these request signals:
### Signals pointing to MVVM
- "simple feature", "screen-level state", "standard iOS pattern"
- small/medium feature without strict state-machine needs
### Signals pointing to MVI
- "state machine", "deterministic transitions", "unidirectional"
- need to replay/serialize state transitions
### Signals pointing to TCA
- "composable", "TestStore", "pointfree", mentions of TCA
- existing TCA codebase or strong child-feature composition needs
### Signals pointing to Clean Architecture
- "layers", "use cases", "dependency rule", "hexagonal"
- stable module boundaries and replaceable infrastructure are priorities
### Signals pointing to VIPER
- "module", "router", "presenter", legacy UIKit codebase
- strict role separation in large UIKit modules
### Signals pointing to Reactive
- "streams", "Combine", "RxSwift", "real-time", "search"
- feature behavior is event-pipeline driven (typeahead, WebSocket, live feeds)
### Signals pointing to MVP
- "passive view", "presenter drives view", "UIKit without observable state"
- migrating from MVC with minimal framework changes
- team prefers explicit command-dispatch over state binding
### Signals pointing to Coordinator
- "navigation", "deep linking", "flow", "routing", "decouple navigation"
- multiple screens need to be reused across different flows
- view controllers or ViewModels currently contain push/present calls
## Validating User-Requested Architectures
When the user pre-selects an architecture, validate it before finalizing:
1. Check fit across:
- UI stack (SwiftUI/UIKit/mixed)
- feature complexity and state model needs
- effect orchestration requirements
- team familiarity and dependency tolerance
- alignment with existing codebase conventions
2. Decide whether the request is a `fit` or a `mismatch`.
3. Respond based on the result:
- `fit`: proceed with requested architecture
- `mismatch`: recommend closest-fit alternative and explain why
If the user insists on a mismatched choice, proceed with the requested architecture but include a risk-mitigation plan.
## Combining Architectures
Some projects use multiple patterns. Common valid combinations:
- **MVVM + Reactive**: MVVM structure with Combine/Rx pipelines inside ViewModels
- **Clean Architecture + MVVM**: Clean layers for domain/data, MVVM for presentation
- **Clean Architecture + TCA**: Clean layers for domain/data, TCA for feature presentation
- **VIPER + Reactive**: VIPER module structure with reactive Interactors
- **MVVM + Coordinator**: MVVM for screen-level state, Coordinator for navigation flows
- **MVP + Coordinator**: MVP for presentation logic, Coordinator for navigation and routing
- **Clean Architecture + MVP**: Clean layers for domain/data, MVP for presentation
When combining, clarify which pattern governs which layer and keep boundaries consistent.
## Recommendation Format
When recommending:
1. Name one pattern and provide a fit result (`fit` or `mismatch`).
2. Give 1-2 concise reasons grounded in user constraints.
3. Cite the reference file.
4. If `mismatch`, include the closest-fit alternative and one trade-off.
5. Apply the selected playbook to the users feature.

View File

@@ -0,0 +1,406 @@
# TCA Playbook (Swift + SwiftUI/UIKit)
Use this reference for strict unidirectional flow, strong composition, and `TestStore`-driven testing.
## Mental Model
```text
View -> store.send(Action)
Reducer(State, Action) -> state mutation + Effect<Action>
Effect emits Action -> reducer
```
Core expectations:
- value-based state
- reducer-driven decisions
- isolated side effects via effects
- dependency injection through TCA dependencies
- feature composition with scoped reducers
## Canonical Feature Shape
Prefer modern TCA with `@Reducer` and `@ObservableState`.
```swift
import ComposableArchitecture
@Reducer
struct CounterFeature {
enum CancelID { case fact }
enum FactError: Error, Equatable {
case unavailable
}
@ObservableState
struct State: Equatable {
var count = 0
var isLoading = false
@Presents var alert: AlertState<Action.Alert>?
}
enum Action: Equatable {
case incrementTapped
case decrementTapped
case factButtonTapped
case factResponse(Result<String, FactError>)
case alert(PresentationAction<Alert>)
enum Alert: Equatable {}
}
@Dependency(\.numberFact) var numberFact
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .incrementTapped:
state.count += 1
return .none
case .decrementTapped:
state.count -= 1
return .none
case .factButtonTapped:
state.isLoading = true
let n = state.count
return .run { send in
do {
let fact = try await numberFact.fetch(n)
await send(.factResponse(.success(fact)))
} catch is CancellationError {
// Cancellation is expected when a new request replaces this one.
} catch {
await send(.factResponse(.failure(.unavailable)))
}
}
.cancellable(id: CancelID.fact, cancelInFlight: true)
case .factResponse(.success(let fact)):
state.isLoading = false
state.alert = AlertState { TextState(fact) }
return .none
case .factResponse(.failure):
state.isLoading = false
state.alert = AlertState { TextState("Could not load fact.") }
return .none
case .alert:
return .none
}
}
.ifLet(\.$alert, action: \.alert)
}
}
```
## View Integration
Rules:
- send actions from the view
- never mutate business state directly in the view
- observe the smallest practical state slice
### Modern Pattern (TCA 1.7+ with `@ObservableState`)
With `@ObservableState`, views access store properties directly — no `WithViewStore` needed.
```swift
struct CounterView: View {
@Bindable var store: StoreOf<CounterFeature>
var body: some View {
VStack {
Text("Count: \(store.count)")
Button("+") { store.send(.incrementTapped) }
Button("-") { store.send(.decrementTapped) }
Button("Fact") { store.send(.factButtonTapped) }
if store.isLoading { ProgressView() }
}
.alert($store.scope(state: \.alert, action: \.alert))
}
}
```
### Legacy Pattern (TCA < 1.7 with `WithViewStore`)
```swift
struct CounterView: View {
let store: StoreOf<CounterFeature>
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
VStack {
Text("Count: \(viewStore.count)")
Button("+") { viewStore.send(.incrementTapped) }
Button("-") { viewStore.send(.decrementTapped) }
Button("Fact") { viewStore.send(.factButtonTapped) }
if viewStore.isLoading { ProgressView() }
}
.alert(store: store.scope(state: \.alert, action: \.alert))
}
}
}
```
UIKit guidance:
- keep a store in the view controller
- subscribe to state changes from the store
- centralize rendering in one method
Concrete UIKit pattern:
```swift
import ComposableArchitecture
import Combine
import UIKit
@MainActor
final class CounterViewController: UIViewController {
private let viewStore: ViewStoreOf<CounterFeature>
private var cancellables = Set<AnyCancellable>()
init(store: StoreOf<CounterFeature>) {
self.viewStore = ViewStore(store, observe: { $0 })
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { return nil }
override func viewDidLoad() {
super.viewDidLoad()
viewStore.publisher
.sink { [weak self] state in
self?.render(state)
}
.store(in: &cancellables)
}
@objc private func incrementTapped() {
viewStore.send(.incrementTapped)
}
private func render(_ state: CounterFeature.State) {
title = "Count: \(state.count)"
// Render labels/buttons/loading from state only.
}
}
```
## Composition Patterns
Use `Scope` for parent-child composition.
```swift
@Reducer
struct AppFeature {
@ObservableState
struct State: Equatable {
var counter = CounterFeature.State()
}
enum Action: Equatable {
case counter(CounterFeature.Action)
}
var body: some ReducerOf<Self> {
Scope(state: \.counter, action: \.counter) {
CounterFeature()
}
}
}
```
Use `IdentifiedArrayOf` and `forEach` for collections with stable identity.
## Dependency Rules
- keep dependency surfaces small and capability-focused
- inject via `@Dependency`
- never place dependencies in state
- avoid singleton calls in reducers
```swift
struct NumberFactClient {
var fetch: @Sendable (Int) async throws -> String
}
extension NumberFactClient: DependencyKey {
static let liveValue = Self(fetch: { number in
"\(number) is a good number."
})
static let testValue = Self(fetch: { _ in
"Test fact"
})
}
extension DependencyValues {
var numberFact: NumberFactClient {
get { self[NumberFactClient.self] }
set { self[NumberFactClient.self] = newValue }
}
}
```
## Effects and Concurrency
Use `.run` for async work and route results back as actions.
For re-entrant work, add cancellation (`.cancellable(id:cancelInFlight:)`) and map failures to explicit actions.
If cancellation is not enough, add request versioning.
## Navigation Pattern
Model navigation in state and drive it through actions.
Common shapes:
- `@Presents var alert: AlertState<Action.Alert>?`
- `destination: Destination.State?`
- Attach a matching `.ifLet` reducer for each presentation action (`alert`, `destination`, etc.).
Keep navigation decisions in reducers and keep views declarative.
## Testing with `TestStore`
Use `TestStore` for deterministic action/state assertions.
Cover success, failure, and cancellation paths in async effects.
```swift
import XCTest
import ComposableArchitecture
@MainActor
final class CounterFeatureTests: XCTestCase {
func testIncrement() async {
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
}
await store.send(.incrementTapped) {
$0.count = 1
}
}
func testFactSuccess() async {
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
} withDependencies: {
$0.numberFact.fetch = { _ in "42 is great" }
}
await store.send(.factButtonTapped) {
$0.isLoading = true
}
await store.receive(.factResponse(.success("42 is great"))) {
$0.isLoading = false
$0.alert = AlertState { TextState("42 is great") }
}
}
func testFactFailure() async {
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
} withDependencies: {
$0.numberFact.fetch = { _ in throw CounterFeature.FactError.unavailable }
}
await store.send(.factButtonTapped) {
$0.isLoading = true
}
await store.receive(.factResponse(.failure(.unavailable))) {
$0.isLoading = false
$0.alert = AlertState { TextState("Could not load fact.") }
}
}
func testFactCancellation_replacesInFlightRequest() async {
let clock = TestClock()
actor Sequence {
var values = ["first", "second"]
func next() -> String { values.removeFirst() }
}
let sequence = Sequence()
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
} withDependencies: {
$0.numberFact.fetch = { _ in
let value = await sequence.next()
try await clock.sleep(for: .seconds(1))
return value
}
}
await store.send(.factButtonTapped) {
$0.isLoading = true
}
await store.send(.factButtonTapped)
await clock.advance(by: .seconds(1))
await store.receive(.factResponse(.success("second"))) {
$0.isLoading = false
$0.alert = AlertState { TextState("second") }
}
}
}
```
## Anti-Patterns and Fixes
1. Massive feature with no composition:
- Smell: giant reducer handling unrelated domains.
- Fix: split into child reducers and compose via `Scope`.
2. Reference types in state:
- Smell: class instances or shared mutable collections in state.
- Fix: keep state value-based and equatable.
3. Business work in views:
- Smell: view calls services or transforms domain data.
- Fix: move logic to reducer/effects and expose render-ready state.
4. Side effects directly in reducer:
- Smell: analytics/network calls inline without effect boundary.
- Fix: route through dependencies and effects.
5. Duplicate state outside store:
- Smell: local `@State` mirrors store state.
- Fix: keep single source of truth in store.
6. Over-observing large state:
- Smell: broad observation triggers unnecessary re-renders.
- Fix: observe scoped state and split view/store boundaries.
7. Missing cancellation:
- Smell: overlapping effects overwrite current intent.
- Fix: use `.cancellable(id:cancelInFlight:)` and request IDs when needed.
## When to Prefer TCA
Prefer TCA when:
- app has many stateful workflows
- test determinism is critical
- composition and modular scaling are required
- effect cancellation correctness matters
Prefer MVVM or lighter MVI variants when:
- app is small and unlikely to grow
- team is not ready for UDF discipline
- feature speed and low ceremony are prioritized
## PR Review Checklist
- State is value-based and equatable.
- Reducer avoids direct side effects.
- Dependencies are injected and overrideable in tests.
- Effects have cancellation strategy where needed.
- Features compose with `Scope`/`forEach`.
- Navigation is modeled in state.
- Tests cover success, failure, and cancellation flows.
- Views render and send actions only.

View File

@@ -0,0 +1,491 @@
# VIPER Playbook (Swift + SwiftUI/UIKit)
Use this reference when strict feature-level separation is needed, especially in large or legacy UIKit codebases.
## Core Components
- View: render UI and forward user actions
- Interactor: execute business logic and coordinate data access
- Presenter: transform entities into display-ready output and control view state
- Entity: domain models used by the feature
- Router: navigation and module assembly
Expected interaction:
```text
View -> Presenter -> Interactor -> Repository/Service -> Interactor -> Presenter -> View
Presenter -> Router (navigation)
```
## Canonical Feature Layout
```text
Feature/
View/
Presenter/
Interactor/
Entity/
Router/
```
Keep one VIPER module per feature to prevent cross-feature leakage.
## Responsibilities
### View
- Render data provided by Presenter.
- Forward user inputs (`didTap...`, `didAppear`, text changes).
- Avoid direct service/repository access.
- In SwiftUI, use an adapter (`@Observable` on iOS 17+ or `ObservableObject` when Combine/UIKit interop is needed) that forwards to Presenter.
### Presenter
- Own presentation flow for the feature.
- Ask Interactor for business results.
- Map entities to view models/display strings.
- Call Router for navigation.
### Interactor
- Execute business rules and use cases.
- Call repositories/services through protocols.
- Return domain results to Presenter.
- Avoid direct view or navigation concerns.
### Router
- Perform navigation transitions.
- Build and wire module dependencies.
### Entity
- Represent domain data and business invariants.
- Avoid UI and framework coupling where possible.
- Keep display formatting out of `Entity`; Presenter maps entity -> display model.
```swift
struct User: Equatable {
let id: UUID
let name: String
let isPremium: Bool
}
struct ProfileViewData: Equatable {
let displayName: String
let badgeText: String?
}
extension ProfileViewData {
init(user: User) {
self.displayName = user.name
self.badgeText = user.isPremium ? "Premium" : nil
}
}
```
## Wiring Pattern
Use boundary protocols and directional references.
```swift
@MainActor
protocol ProfileView: AnyObject {
func showLoading(_ isLoading: Bool)
func show(profile: ProfileViewData)
func showError(message: String)
}
protocol ProfileInteracting {
func loadUser() async throws -> User
}
protocol ProfileRouting {
func showSettings()
}
@MainActor
final class ProfilePresenter {
weak var view: ProfileView?
private let interactor: ProfileInteracting
private let router: ProfileRouting
private var loadTask: Task<Void, Never>?
private var latestLoadRequestID: UUID?
init(interactor: ProfileInteracting, router: ProfileRouting) {
self.interactor = interactor
self.router = router
}
func load() {
let requestID = UUID()
latestLoadRequestID = requestID
loadTask?.cancel()
view?.showLoading(true)
loadTask = Task {
do {
let user = try await interactor.loadUser()
try Task.checkCancellation()
guard latestLoadRequestID == requestID else { return }
view?.show(profile: ProfileViewData(user: user))
} catch is CancellationError {
// Cancelled by a newer load request.
} catch {
guard latestLoadRequestID == requestID else { return }
view?.showError(message: "Failed to load profile. Please try again.")
}
guard latestLoadRequestID == requestID else { return }
view?.showLoading(false)
}
}
func didTapSettings() {
router.showSettings()
}
deinit {
loadTask?.cancel()
}
}
```
Keep `view` weak to avoid retain cycles.
Keep presenter/view updates on the main actor so UI calls are thread-safe.
## Assembly Guidance
Create modules via Router/Assembly factory:
- instantiate View, Presenter, Interactor, Router
- inject protocols, not concrete global singletons
- set references once during build
This centralizes wiring and reduces circular dependency mistakes.
```swift
enum ProfileModule {
static func build(
userRepository: UserRepository,
navigationController: UINavigationController
) -> UIViewController {
let interactor = ProfileInteractor(repository: userRepository)
let router = ProfileRouter(navigationController: navigationController)
let presenter = ProfilePresenter(interactor: interactor, router: router)
let viewController = ProfileViewController(presenter: presenter)
presenter.view = viewController
return viewController
}
}
```
Rules:
- keep the factory method as the single entry point for module creation
- inject external dependencies (repositories, services) from the caller
- set weak back-references (e.g., `presenter.view`) after construction
SwiftUI integration option:
- keep Presenter/Interactor/Router unchanged
- wrap SwiftUI feature view in `UIHostingController`
- bridge Presenter output through a small adapter object
- for pure SwiftUI apps, inject a SwiftUI router object instead of requiring `UINavigationController`
```swift
import SwiftUI
import UIKit
@MainActor
final class ProfileViewAdapter: ObservableObject, ProfileView {
@Published private(set) var name = ""
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String?
private let presenter: ProfilePresenter
init(presenter: ProfilePresenter) {
self.presenter = presenter
}
func showLoading(_ isLoading: Bool) {
self.isLoading = isLoading
}
func show(profile: ProfileViewData) {
self.name = profile.displayName
self.errorMessage = nil
}
func showError(message: String) {
self.errorMessage = message
}
func load() { presenter.load() }
func didTapSettings() { presenter.didTapSettings() }
}
struct ProfileScreen: View {
@ObservedObject var adapter: ProfileViewAdapter
var body: some View {
VStack {
Text(adapter.name)
if adapter.isLoading { ProgressView() }
if let errorMessage = adapter.errorMessage {
Text(errorMessage)
}
Button("Settings") { adapter.didTapSettings() }
}
.task { adapter.load() }
}
}
enum ProfileModuleSwiftUI {
static func build(
userRepository: UserRepository,
navigationController: UINavigationController
) -> UIViewController {
let interactor = ProfileInteractor(repository: userRepository)
let router = ProfileRouter(navigationController: navigationController)
let presenter = ProfilePresenter(interactor: interactor, router: router)
let adapter = ProfileViewAdapter(presenter: presenter)
presenter.view = adapter
return UIHostingController(rootView: ProfileScreen(adapter: adapter))
}
}
```
Pure SwiftUI app option (no `UINavigationController`):
```swift
import SwiftUI
enum AppDestination: Hashable {
case settings
}
@MainActor
@Observable
final class AppRouter {
var path: [AppDestination] = []
func push(_ destination: AppDestination) {
path.append(destination)
}
}
@MainActor
final class ProfileSwiftUIRouter: ProfileRouting {
private let appRouter: AppRouter
init(appRouter: AppRouter) {
self.appRouter = appRouter
}
func showSettings() {
appRouter.push(.settings)
}
}
enum ProfileModulePureSwiftUI {
@MainActor
static func build(
userRepository: UserRepository,
appRouter: AppRouter
) -> ProfileScreen {
let interactor = ProfileInteractor(repository: userRepository)
let router = ProfileSwiftUIRouter(appRouter: appRouter)
let presenter = ProfilePresenter(interactor: interactor, router: router)
let adapter = ProfileViewAdapter(presenter: presenter)
presenter.view = adapter
return ProfileScreen(adapter: adapter)
}
}
```
At app root, bind the shared router path to `NavigationStack`:
```swift
struct AppRootView: View {
@State private var appRouter = AppRouter()
var body: some View {
@Bindable var appRouter = appRouter
NavigationStack(path: $appRouter.path) {
ProfileModulePureSwiftUI.build(
userRepository: LiveUserRepository(),
appRouter: appRouter
)
.navigationDestination(for: AppDestination.self) { destination in
switch destination {
case .settings:
SettingsView()
}
}
}
}
}
```
## Concurrency and Cancellation
When Presenter coordinates async work, track active tasks and cancel stale requests. The `ProfilePresenter` shown in the Wiring Pattern section above already implements the full cancellation strategy — it holds a `loadTask: Task<Void, Never>?`, a `latestLoadRequestID: UUID?`, and handles `CancellationError` explicitly to guard against stale UI updates.
Rules:
- cancel in-flight tasks before issuing new requests
- handle `CancellationError` explicitly to avoid stale UI updates
- gate UI updates by request identity so only the latest request can update view state
- cancel all tasks on module teardown
- keep presenter intent methods synchronous (`func load()`), and manage async tasks internally
## Anti-Patterns and Fixes
1. Massive Presenter:
- Smell: presenter contains business logic, formatting, networking, and navigation details.
- Fix: move business logic to Interactor and formatting helpers; keep Presenter orchestration-focused.
2. Interactor performing navigation:
- Smell: interactor directly pushes/presents screens.
- Fix: route navigation through Router called by Presenter.
3. Circular dependencies and strong cycles:
- Smell: View <-> Presenter <-> Router retain each other strongly.
- Fix: use boundary protocols and weak references where required.
4. View doing business work:
- Smell: View transforms data or calls services directly.
- Fix: move logic into Presenter/Interactor.
5. Router containing business logic:
- Smell: Router decides domain outcomes.
- Fix: keep Router limited to navigation and assembly.
## Testing Strategy
Prioritize isolated tests per component:
- Presenter tests with mocked View/Interactor/Router
- Interactor tests with mocked repositories/services
- Router tests for navigation triggers where feasible
Testing rules:
- assert interactions and outputs, not concrete implementations
- avoid network in unit tests
- verify presenter handles success and failure states
- verify Presenter-to-View error contract (`showError(message:)`) for failure paths
- test cancellation behavior when a newer load replaces an in-flight request
- keep async tests deterministic with controlled stubs/clocks (avoid sleeps)
Use the cancellation-aware presenter from the "Concurrency and Cancellation" section for cancellation-path tests.
```swift
@MainActor
final class MockProfileView: ProfileView {
var shownName: String?
var shownError: String?
var isLoading = false
func showLoading(_ isLoading: Bool) { self.isLoading = isLoading }
func show(profile: ProfileViewData) {
shownName = profile.displayName
}
func showError(message: String) {
shownError = message
}
}
struct StubProfileInteractor: ProfileInteracting {
var load: () async throws -> User
func loadUser() async throws -> User { try await load() }
}
final class SpyProfileRouter: ProfileRouting {
var didShowSettings = false
func showSettings() { didShowSettings = true }
}
@MainActor
final class ProfilePresenterTests: XCTestCase {
func test_load_success_showsUserName() async {
let user = User(id: UUID(), name: "Alice", isPremium: false)
let view = MockProfileView()
let presenter = ProfilePresenter(
interactor: StubProfileInteractor(load: { user }),
router: SpyProfileRouter()
)
presenter.view = view
presenter.load()
await Task.yield()
XCTAssertEqual(view.shownName, "Alice")
}
func test_load_failure_showsError() async {
let view = MockProfileView()
let presenter = ProfilePresenter(
interactor: StubProfileInteractor(load: { throw TestError.notFound }),
router: SpyProfileRouter()
)
presenter.view = view
presenter.load()
await Task.yield()
XCTAssertEqual(view.shownError, "Failed to load profile. Please try again.")
}
func test_didTapSettings_routesToSettings() {
let router = SpyProfileRouter()
let presenter = ProfilePresenter(
interactor: StubProfileInteractor(load: { User(id: UUID(), name: "", isPremium: false) }),
router: router
)
presenter.didTapSettings()
XCTAssertTrue(router.didShowSettings)
}
func test_load_cancellation_doesNotOverwriteExistingName() async {
let view = MockProfileView()
view.shownName = "Current"
let presenter = ProfilePresenter(
interactor: StubProfileInteractor(load: { throw CancellationError() }),
router: SpyProfileRouter()
)
presenter.view = view
presenter.load()
await Task.yield()
XCTAssertEqual(view.shownName, "Current")
}
}
private enum TestError: Error { case notFound }
```
## When to Prefer VIPER
Prefer VIPER when:
- multiple teams need independently owned feature modules with explicit boundaries
- strict role separation reduces architecture drift in long-lived codebases
- interactor-level business rules must be testable without booting UI screens
- modular compilation and clear dependency direction are high priorities
- UIKit-heavy codebase benefits from router-driven assembly/navigation
Prefer lighter patterns when:
- app is small or prototyping quickly
- ceremony cost outweighs boundary/testability benefits
Compared with organized MVVM, VIPER usually adds more setup but enforces role boundaries more strongly at scale, especially when teams and modules are decoupled.
## PR Review Checklist
- Component responsibilities are respected (View/Interactor/Presenter/Router separated).
- Presenter does not own business logic implementation details.
- Interactor does not navigate.
- Router handles only navigation and module assembly.
- Boundary protocols avoid concrete coupling.
- Retain cycles are prevented with weak references where needed.
- Tests cover presenter orchestration and interactor business rules.

View File

@@ -0,0 +1,124 @@
---
name: swift-concurrency-pro
description: Reviews Swift code for concurrency correctness, modern API usage, and common async/await pitfalls. Use when reading, writing, or reviewing Swift concurrency code.
license: MIT
metadata:
author: Paul Hudson
version: "1.0"
---
Review Swift concurrency code for correctness, modern API usage, and adherence to project conventions. Report only genuine problems - do not nitpick or invent issues.
Review process:
1. Scan for known-dangerous patterns using `references/hotspots.md` to prioritize what to inspect.
1. Check for recent Swift 6.2 concurrency behavior using `references/new-features.md`.
1. Validate actor usage for reentrancy and isolation correctness using `references/actors.md`.
1. Ensure structured concurrency is preferred over unstructured where appropriate using `references/structured.md`.
1. Check unstructured task usage for correctness using `references/unstructured.md`.
1. Verify cancellation is handled correctly using `references/cancellation.md`.
1. Validate async stream and continuation usage using `references/async-streams.md`.
1. Check bridging code between sync and async worlds using `references/bridging.md`.
1. Review any legacy concurrency migrations using `references/interop.md`.
1. Cross-check against common failure modes using `references/bug-patterns.md`.
1. If the project has strict-concurrency errors, map diagnostics to fixes using `references/diagnostics.md`.
1. If reviewing tests, check async test patterns using `references/testing.md`.
If doing a partial review, load only the relevant reference files.
## Core Instructions
- Target Swift 6.2 or later with strict concurrency checking.
- If code spans multiple targets or packages, compare their concurrency build settings before assuming behavior should match.
- Prefer structured concurrency (task groups) over unstructured (`Task {}`).
- Prefer Swift concurrency over Grand Central Dispatch for new code. GCD is still acceptable in low-level code, framework interop, or performance-critical synchronous work where queues and locks are the right tool don't flag these as errors.
- If an API offers both `async`/`await` and closure-based variants, always prefer `async`/`await`.
- Do not introduce third-party concurrency frameworks without asking first.
- Do not suggest `@unchecked Sendable` to fix compiler errors. It silences the diagnostic without fixing the underlying race. Prefer actors, value types, or `sending` parameters instead. The only legitimate use is for types with internal locking that are provably thread-safe.
## Output Format
Organize findings by file. For each issue:
1. State the file and relevant line(s).
2. Name the rule being violated.
3. Show a brief before/after code fix.
Skip files with no issues. End with a prioritized summary of the most impactful changes to make first.
Example output:
### DataLoader.swift
**Line 18: Actor reentrancy state may have changed across the `await`.**
```swift
// Before
actor Cache {
var items: [String: Data] = [:]
func fetch(_ key: String) async throws -> Data {
if items[key] == nil {
items[key] = try await download(key)
}
return items[key]!
}
}
// After
actor Cache {
var items: [String: Data] = [:]
func fetch(_ key: String) async throws -> Data {
if let existing = items[key] { return existing }
let data = try await download(key)
items[key] = data
return data
}
}
```
**Line 34: Use `withTaskGroup` instead of creating tasks in a loop.**
```swift
// Before
for url in urls {
Task { try await fetch(url) }
}
// After
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask { try await fetch(url) }
}
for try await result in group {
process(result)
}
}
```
### Summary
1. **Correctness (high):** Actor reentrancy bug on line 18 may cause duplicate downloads and a force-unwrap crash.
2. **Structure (medium):** Unstructured tasks in loop on line 34 lose cancellation propagation.
End of example.
## References
- `references/hotspots.md` - Grep targets for code review: known-dangerous patterns and what to check for each.
- `references/new-features.md` - Swift 6.2 changes that alter review advice: default actor isolation, isolated conformances, caller-actor async behavior, `@concurrent`, `Task.immediate`, task naming, and priority escalation.
- `references/actors.md` - Actor reentrancy, shared-state annotations, global actor inference, and isolation patterns.
- `references/structured.md` - Task groups over loops, discarding task groups, concurrency limits.
- `references/unstructured.md` - Task vs Task.detached, when Task {} is a code smell.
- `references/cancellation.md` - Cancellation propagation, cooperative checking, broken cancellation patterns.
- `references/async-streams.md` - AsyncStream factory, continuation lifecycle, back-pressure.
- `references/bridging.md` - Checked continuations, wrapping legacy APIs, `@unchecked Sendable`.
- `references/interop.md` - Migrating from GCD, `Mutex`/locks, completion handlers, delegates, and Combine.
- `references/bug-patterns.md` - Common concurrency failure modes and their fixes.
- `references/diagnostics.md` - Strict-concurrency compiler errors, protocol conformance fixes, and likely remedies.
- `references/testing.md` - Async test strategy with Swift Testing, race detection, avoiding timing-based tests.

View File

@@ -0,0 +1,10 @@
interface:
display_name: "Swift Concurrency Pro"
short_description: "Reviews Swift concurrency code for modern best practices."
icon_small: "./assets/swift-concurrency-pro-icon.svg"
icon_large: "./assets/swift-concurrency-pro-icon.png"
brand_color: "#FF412E"
default_prompt: "Use $swift-concurrency-pro to review my project."
policy:
allow_implicit_invocation: true

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="227.72" height="227.72" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 227.72 227.72">
<defs>
<style>
.st0 {
fill: none;
stroke: url(#linear-gradient1);
stroke-miterlimit: 10;
stroke-width: 6px;
}
.st1 {
fill: url(#linear-gradient);
fill-rule: evenodd;
}
</style>
<linearGradient id="linear-gradient" x1="58.405" y1="293.045" x2="178.785" y2="413.425" gradientTransform="translate(0 -240)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#ff8a30"/>
<stop offset="1" stop-color="#ff2e2e"/>
</linearGradient>
<linearGradient id="linear-gradient1" x1="33.349" y1="281.349" x2="194.371" y2="442.371" gradientTransform="translate(0 -248)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#00eeaf"/>
<stop offset="0" stop-color="#ff8a30"/>
<stop offset="1" stop-color="#ff2e2e"/>
</linearGradient>
</defs>
<path class="st1" d="M165.43,131.08c3.87-8.17,16.03-46.62-38.6-85.38,7.61,7.03,40.19,39.2,26.05,75.5-34.2-25.27-87.91-66.75-87.91-66.75,0,0,63.69,60.57,83.58,79.15,19.9,18.59,26.97,27.71,26.47,43.59,0,.01,14.58-23.23-9.59-46.11ZM130.89,145.79c-39.35,18.56-78.42-20.82-78.42-20.82,0,0,21.12,26.7,45.19,35.06,33.72,11.71,54.61-9.64,54.61-9.64L52.42,59.03s56.43,62.62,78.47,86.76h0Z"/>
<circle class="st0" cx="113.86" cy="113.86" r="110.86"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,155 @@
# Actors
## Reentrancy
**Important:** This is the most common concurrency bug LLMs produce: after every `await` inside an actor, all assumptions about the actor's state are invalidated because other calls may have run in the meantime.
```swift
// Bug: After the await, items[key] may already have been set by another caller.
// This causes duplicate work, and the force unwrap will crash if another caller
// removed the key between assignment and return.
actor VideoCache {
var items: [URL: Video] = [:]
func video(for url: URL) async throws -> Video {
if items[url] == nil {
items[url] = try await downloadVideo(url)
}
return items[url]!
}
}
```
Fix: capture the result in a local, then assign. **Never assume state is unchanged after `await`.**
```swift
actor VideoCache {
var items: [URL: Video] = [:]
func video(for url: URL) async throws -> Video {
if let cached = items[url] { return cached }
let video = try await downloadVideo(url)
items[url] = video
return video
}
}
```
To avoid two callers both downloading the same URL, you could try storing in-flight tasks similar to this:
```swift
actor VideoCache {
var items: [URL: Video] = [:]
var inFlight: [URL: Task<Video, Error>] = [:]
func video(for url: URL) async throws -> Video {
if let cached = items[url] { return cached }
if let task = inFlight[url] {
return try await task.value
}
let task = Task {
try await downloadVideo(url)
}
inFlight[url] = task
do {
let video = try await task.value
items[url] = video
inFlight[url] = nil
return video
} catch {
inFlight[url] = nil
throw error
}
}
}
```
## Protecting global and static state
Global and static mutable variables need an explicit plan for isolation.
For shared globals, describe the protection mechanism the compiler can rely on:
- `@MainActor` when the symbol belongs to main-actor code and callers should keep synchronous access there. (This is particularly important for any code that interacts with or updates the UI.)
- `@unchecked Sendable` when safety already comes from locks, queues, or another manual scheme the compiler cannot prove. (**Important:** This requires a high standard of coding to get right, so check carefully.)
- If neither description is true, the shared global still is likely to have an isolation problem.
Example:
```swift
@MainActor
final class Library {
static let shared = Library()
var books = [Book]()
}
```
With main-actor default isolation enabled for the target, this annotation may be implicit  check for the setting!
**Note:** `@preconcurrency` can relax an older protocol boundary when isolated conformance is unavailable. Keep it as a fallback only if there is no alternative.
## Global actor inference rules
`@MainActor` propagates in these cases, so don't redundantly annotate:
- A subclass of a `@MainActor` class is also `@MainActor`.
- Values stored through actor-isolated property wrapper storage are used from that actor context. (This includes older, built-in property wrappers, such as `@StateObject`.)
- Conforming to a `@MainActor` protocol infers `@MainActor` on the entire conforming type, including members unrelated to the protocol. For mismatches with non-isolated protocols, see `diagnostics.md`. (SwiftUIs `View` is a `@MainActor` protocol.) For more help with SwiftUI, suggest the [SwiftUI Pro agent skill](https://github.com/twostraws/swiftui-agent-skill).
- Extensions of a `@MainActor` type inherit that isolation. Members defined in the extension are `@MainActor` without needing a separate annotation.
`@MainActor` does *not* propagate to:
- Closures passed to non-isolated functions (unless the parameter is explicitly `@MainActor`).
## `isolated` parameters
Use `isolated` to accept any actor instance and run on its executor, without the function itself being tied to a specific actor:
```swift
func updateUI(on actor: isolated MainActor) {
// Runs on the main actor
}
```
This is useful for code that needs to work with the caller's isolation context.
## `isolated deinit`
For `isolated deinit` on actor-isolated classes, see `new-features.md`.
## What a custom actor changes
A custom actor introduces a separate serialized access boundary.
Review consequences:
- External callers must use `await`.
- Values crossing the boundary must satisfy `Sendable`.
- Reentrancy rules apply after every suspension point inside the actor.
Flag actor types whose API mostly forwards work or owns little mutable state.
Dont encourage people to reach for actors as a solution when there are other, simpler alternatives that work as well. Recommend authors such as Matt Massicotte as further reading, e.g. <https://www.massicotte.org/actors/>.
## Making assertions
Global actors have an `assertIsolated()` method that is helpful for debugging because it causes debug builds to halt if the current task is not executing on the actor's serial executor.
For example, this checks that the code is running on the main actor:
func refresh() {
MainActor.assertIsolated()
// do your work here
}
**Important:** `assertIsolated()` only operates in debug builds; like regular assertions, it is compiled out of release builds, so it has no impact on shipping performance.

View File

@@ -0,0 +1,67 @@
# Async streams
## Prefer `makeStream(of:)` factory
The modern way to create an `AsyncStream` is the static factory method, which returns both the stream and its continuation as a tuple. This avoids capturing the continuation in a closure.
```swift
// OLD: Closure-based, awkward to store the continuation.
var continuation: AsyncStream<Event>.Continuation?
let stream = AsyncStream<Event> { cont in
continuation = cont
}
// NEW: Clean, no closure capture needed.
let (stream, continuation) = AsyncStream.makeStream(of: Event.self)
```
This also works with `AsyncThrowingStream.makeStream(of:throwing:)`.
## Continuation lifecycle
A continuation must always be finished exactly once. Failing to finish it causes the consumer's `for await` loop to hang indefinitely. Finishing it twice is a programmer error (although `AsyncStream.Continuation` tolerates it, `CheckedContinuation` does not).
Always finish in cleanup paths:
```swift
let (stream, continuation) = AsyncStream.makeStream(of: Event.self)
let monitor = NetworkMonitor()
monitor.onEvent = { event in
continuation.yield(event)
}
monitor.onComplete = {
continuation.finish()
}
// If the monitor can be deallocated before completing:
continuation.onTermination = { _ in
monitor.stop()
}
```
## Buffering and back pressure
`AsyncStream` has a default buffer of unlimited size. For high-throughput producers, this can cause unbounded memory growth. Specify a buffering policy:
```swift
let (stream, continuation) = AsyncStream.makeStream(
of: SensorReading.self,
bufferingPolicy: .bufferingNewest(100)
)
```
Choose from:
- `.bufferingNewest(n)` keeps the most recent `n` elements, dropping older ones.
- `.bufferingOldest(n)` keeps the first `n` elements, dropping newer ones.
- `.unbounded` is the default; use only when the consumer keeps up.
## `for await` and cancellation
A `for await` loop automatically stops when the task is cancelled or the stream finishes. You do not need to manually check cancellation inside the loop but code *after* the loop does run, so handle cleanup there if needed.

View File

@@ -0,0 +1,52 @@
# Bridging sync and async code
## Checked continuations
`withCheckedContinuation` and `withCheckedThrowingContinuation` wrap callback-based APIs into async functions. The critical rule is this: **the continuation must be resumed exactly once on every code path.**
- Resuming zero times: the caller hangs forever.
- Resuming twice: a runtime crash.
So, audit every code path. If the callback might not fire (e.g., the object is deallocated), ensure you still resume the continuation.
Default to `withCheckedContinuation` / `withCheckedThrowingContinuation` everywhere, including production builds. The runtime checks catch double-resume and missing-resume bugs that are otherwise extremely hard to diagnose.
Only consider switching to the `withUnsafe` continuation variants after profiling proves the checked version is a bottleneck in a hot path, but this is rare in practice.
## Wrapping delegate-based APIs
For delegate patterns that deliver multiple values over time, use `AsyncStream`. Use `makeStream(of:)` to get the stream and continuation as a pair, and use `onTermination` to clean up when the consumer stops listening.
Make sure that:
- The continuation is stored as a property so delegate callbacks can yield into it.
- `onTermination` runs when the consumer's `for await` loop ends (or the task is cancelled), so it's the right place to stop the underlying service.
This pattern supports a single consumer. If you need multiple consumers, consider broadcasting through an `@Observable` class instead.
## Runtime actor assertions in callback code
Callback-based APIs are a common place for actor assumptions to fail at runtime.
- If a callback reaches main-actor state without carrying that guarantee in the type system, Swift 6 runtime checks can trap instead of silently racing.
- Use `MainActor.assumeIsolated()` only when the callback really is main-actor-bound and you are encoding a guarantee the compiler cannot see.
## `@unchecked Sendable`
This silences the compiler's Sendable checks entirely. It is a promise to the compiler that you have verified thread safety yourself, which is a high bar to clear evaluate such code very carefully.
Legitimate uses:
- Types that use internal locking (e.g., `os_unfair_lock`, `NSLock`, etc) and are genuinely thread-safe.
- Reference types whose mutable state is protected by an actor in practice but can't express that to the compiler for some reason.
Red flags:
- Applying `@unchecked Sendable` to silence a compiler error without understanding why the error exists. (This was previously a Fix-It suggestion in Xcode, so its not uncommon.)
- Applying it to a class with mutable `var` properties and no synchronization.
- Using it as a workaround or shortcut instead of restructuring the code to use value types or actors as appropriate.
Before reaching for `@unchecked Sendable`, check whether Swift 6's region-based isolation already solves the problem many cases that previously required it now compile cleanly.

View File

@@ -0,0 +1,100 @@
# Bug patterns
Real concurrency failure modes that LLMs produce frequently, with the preferred fix for each.
## Actor reentrancy: check-then-act across `await`
**Failure:** Actor method checks state, awaits, then acts on the stale check. Other callers may have mutated state during the suspension.
```swift
// BUG: Two callers can both see nil and both download.
// The force unwrap can crash if a third caller clears the cache mid-flight.
actor Cache {
var data: [String: Data] = [:]
func load(_ key: String) async throws -> Data {
if data[key] == nil {
data[key] = try await download(key)
}
return data[key]!
}
}
```
**Fix:** Capture the async result into a local before writing. For deduplication, store in-flight `Task` handles. See `actors.md` for the full pattern.
## Continuation resumed zero times
**Failure:** A `withCheckedThrowingContinuation` callback never fires (object deallocated, network timeout with no callback, early return before registering the handler, etc). The caller hangs forever.
**Fix:** Audit every code path to confirm the continuation is resumed. If the underlying API can silently drop the callback, add a timeout or restructure so the caller isn't left waiting. Always use `withCheckedThrowingContinuation` (not the unsafe variant) so that missed resumes are easier to diagnose.
## Continuation resumed twice
**Failure:** Two callbacks (e.g., a success handler and a cancellation handler) both resume the same continuation. `CheckedContinuation` traps at runtime; `UnsafeContinuation` causes undefined behavior.
**Fix:** Restructure the callback wiring so only one path can reach the continuation. If that isn't possible, guard with a `Bool` flag or use an `actor` to serialize access. Always default to `CheckedContinuation` so double resumes surface immediately during development and testing.
## Unstructured tasks in a loop
**Failure:** `for item in items { Task { await process(item) } }` creates fire-and-forget tasks with no cancellation propagation, no error collection, and no way to await completion.
**Fix:** Use `withTaskGroup` or `withThrowingTaskGroup`. See `structured.md`.
## Swallowed errors in Task closures
**Failure:** `Task { try await riskyWork() }` if `riskyWork` throws, the error is silently lost. The user sees nothing; the operation just doesn't happen.
**Fix:** Handle the error inside the closure show an alert, log to a visible surface, or propagate via a `@State` error property.
```swift
Task {
do {
try await riskyWork()
} catch {
self.errorMessage = error.localizedDescription
}
}
```
## Blocking the main actor with synchronous work
**Failure:** CPU-intensive work runs on `@MainActor` (or inside `Task {}` called from `@MainActor`), causing UI freezes. In Swift 6.2 this is more likely because `nonisolated` async functions now stay on the caller's executor by default.
**Fix:** Move the expensive work into an explicitly offloaded function using `@concurrent`, or use `Task.detached` as a last resort.
## Unbounded AsyncStream buffer
**Failure:** A high-throughput producer yields values faster than the consumer processes them. With the default `.unbounded` buffering policy, memory grows without limit.
**Fix:** Specify `.bufferingNewest(n)` or `.bufferingOldest(n)`. See `async-streams.md`.
## Ignoring `CancellationError` in catch blocks
**Failure:** A `catch` block retries or shows an error alert for `CancellationError`, which is a normal lifecycle event (e.g., user navigated away).
**Fix:** Check for cancellation before handling other errors:
```swift
do {
try await loadData()
} catch is CancellationError {
// Normal view disappeared or task was cancelled. Do nothing.
} catch {
self.errorMessage = error.localizedDescription
}
```
## `@unchecked Sendable` hiding real races
**Failure:** A class is marked `@unchecked Sendable` to suppress compiler errors, but its mutable `var` properties have no synchronization. The data race still exists at runtime.
**Fix:** Restructure to use value types, use an `actor`, or move state behind a lock. See `bridging.md`.

View File

@@ -0,0 +1,107 @@
# Cancellation
Cancellation in Swift concurrency is cooperative. Setting the cancelled flag does nothing unless the running code checks it.
## How cancellation propagates
- Cancelling a parent task cancels all its children (structured concurrency).
- Cancelling a task group cancels all child tasks in that group.
- `Task {}` and `Task.detached {}` are unstructured they must be cancelled explicitly by storing and calling `.cancel()` on the task handle.
- SwiftUI's `.task()` modifier cancels its task automatically when the view disappears. This is the primary reason to prefer `.task()` over `onAppear()` or loose `Task {}` in views.
## Checking for cancellation
Its important to use these inside long-running or looping async work, but only when its safe to actually exit:
- `try Task.checkCancellation()` throws `CancellationError` if cancelled. Preferred in throwing contexts.
- `Task.isCancelled` returns `Bool`. Use in non-throwing contexts or when you need cleanup before exiting.
```swift
func processAll(_ items: [Item]) async throws {
for item in items {
try Task.checkCancellation()
try await process(item)
}
}
```
Functions that call other async functions get implicit cancellation checks at each `await` suspension point but only if the called function itself checks. CPU-bound loops with no `await` will never see cancellation unless you check explicitly.
## `withTaskCancellationHandler`
Bridges Swift cancellation to legacy APIs that have their own cancel mechanism. The `onCancel` closure fires immediately when cancellation is requested even while the async body is suspended and may run on any thread.
```swift
func fetchImage(_ url: URL) async throws -> Data {
var request = URLRequest(url: url)
return try await withTaskCancellationHandler {
let (data, _) = try await URLSession.shared.data(for: request)
return data
} onCancel: {
// No direct handle to cancel here URLSession.data(for:) already
// checks for task cancellation internally. This pattern is most
// useful when wrapping APIs that return a cancellable handle.
}
}
```
A more realistic use is wrapping something that gives you a cancel handle:
```swift
func observe() async throws -> [Change] {
let query = CKQuery(recordType: "Item", predicate: NSPredicate(value: true))
let operation = CKQueryOperation(query: query)
return try await withTaskCancellationHandler {
try await performOperation(operation)
} onCancel: {
operation.cancel()
}
}
```
## Broken cancellation patterns
**Catching and ignoring `CancellationError`:**
```swift
// BROKEN: Retries or shows an alert for a normal lifecycle event.
catch {
showAlert(error.localizedDescription)
}
```
Always prefer filtering out `CancellationError` before handling other errors. See `bug-patterns.md`.
**Forgetting to cancel stored tasks:**
```swift
// BROKEN: The task keeps running after the object is done with it.
class ViewModel {
var loadTask: Task<Void, Never>?
func load() {
loadTask = Task { await fetchData() }
}
}
```
Cancel the previous task before starting a new one, and cancel on teardown:
```swift
func load() {
loadTask?.cancel()
loadTask = Task { await fetchData() }
}
deinit {
loadTask?.cancel()
}
```
**No cancellation checks in CPU-bound work:**
A tight computational loop with no `await` points will run to completion even if cancelled, because there are no suspension points where cancellation can take effect. Insert periodic `try Task.checkCancellation()` calls wherever its safe.

View File

@@ -0,0 +1,70 @@
# Diagnostics
Maps common strict-concurrency compiler errors to likely fixes.
## "Sending 'x' risks causing data races"
The compiler found a value crossing an isolation boundary where it could still be accessed from the sending side.
Likely fixes (try in order):
1. **Check whether region-based isolation already handles it.** If the sender demonstrably stops using the value after passing it, the compiler may accept it without changes. Avoid adding `Sendable` prematurely.
2. **Mark the parameter `sending`.** This tells the compiler the caller transfers ownership and won't touch the value afterward. (This can be useful, but is not that common.)
3. **Make the type `Sendable`** if it genuinely can be shared safely (value type, immutable class, or internally synchronized).
4. **Check whether `nonisolated(nonsending)` resolves it.** If the function no longer hops executors, the value may not actually cross a boundary.
5. **Last resort: `@unchecked Sendable`** only if the type uses manual synchronization (locks) and you've verified correctness. See `bridging.md`.
## "Static property 'x' is not concurrency-safe"
A global or static variable is accessible from multiple isolation domains with no protection.
Likely fixes:
1. **Annotate the declaration with `@MainActor`**: `@MainActor static let shared = MyType()`. This is the simplest code-local fix.
2. **If the value is truly constant and immutable**, consider whether it can conform to `Sendable` (e.g., a `let`-only struct). The compiler won't flag `Sendable` constants.
3. **Use `nonisolated(unsafe)`** only for genuinely immutable state where the compiler can't prove safety (e.g., C interop constants). This is a dangerous tool, and misuse will hide real races.
4. **If the entire module is predominantly single-threaded**, default main-actor isolation may explain why similar declarations behave differently in another target. That's a build-setting difference, not a code fix.
## "Capture of 'x' with non-sendable type in a `@Sendable` closure"
A closure that crosses isolation boundaries (e.g., passed to `Task {}`, `Task.detached {}`, or `addTask`) captures a non-Sendable value.
Likely fixes:
1. **Check whether the captured value can be made `Sendable`.** Structs and enums with only `Sendable` stored properties just need the conformance declared. Final classes with immutable (`let`) stored properties can conform too.
2. **Restructure to avoid the capture.** Pass the needed data as a parameter to the task rather than closing over a large non-Sendable object. For example, `let id = object.id; Task { use(id) }`
3. **Move the work onto the same actor.** If the closure doesn't need to run concurrently, keep it on the caller's actor.
4. **Use `sending` on the parameter** if you can transfer ownership cleanly. This is relatively niche.
Its tempting to reach for `@unchecked Sendable`, but rarely a good idea unless the user is *absolutely certain* their code is safe.
## "Conformance of 'X' to protocol 'Y' crosses into main actor-isolated code and can cause data races"
The protocol and the type describe different call boundaries. Fix the boundary mismatch directly:
| Actual requirement | Shape to use |
|---|---|
| Type-level actor isolation is incidental rather than required | Remove the type isolation. See `actors.md`. |
| The conformance should only be usable on `MainActor` | `extension MyType: @MainActor SomeProtocol {}` |
These are different boundary choices, not interchangeable suppressions.
## "Expression is 'async' but is not marked with 'await'"
A call crosses an isolation boundary and requires an async hop. This often surprises when calling actor-isolated methods from outside the actor, or when accessing `@MainActor` state from a non-isolated context.
Likely fix: Add `await`. If the call is in synchronous code that cannot be made async, wrap it in `Task {}` (but see `unstructured.md` for when that's appropriate).
## "Main actor-isolated conformance of 'X' to 'Y' cannot be used in nonisolated context"
An isolated conformance (e.g., `extension X: @MainActor Y`) is being used from code that doesn't share that isolation. The compiler prevents this because calling the protocol methods off-actor would be a data race.
Likely fixes:
1. **Move the use site onto the same actor.** If the consuming code can be `@MainActor`, the conformance is usable.
2. **Remove the isolation from the conformance** if the protocol methods don't actually need actor-protected state.

View File

@@ -0,0 +1,47 @@
# Hotspots
Search targets for concurrency review. When any of these appear in code, inspect carefully using the referenced rules.
## `DispatchQueue`
In app-level code, `DispatchQueue.main.async`, `DispatchQueue.global()`, and custom serial queues usually have a Swift concurrency equivalent see `interop.md`. However, GCD can still be appropriate in low-level libraries, framework interop, and performance-critical synchronous sections where queues or locks are the right tool. Check the context carefully before flagging.
## `Task.detached`
Rarely correct. Usually means the author wanted background execution but should have used `@concurrent` (Swift 6.2) or a task group. Check whether shedding actor isolation and priority is truly intentional. See `unstructured.md`.
## `Task {}` inside a loop
Frequently a bad idea evaluate whether it should be a task group instead. See `structured.md`.
## `withCheckedContinuation` / `withCheckedThrowingContinuation`
Audit every code path to ensure the continuation is resumed exactly once. Watch for early returns, thrown errors, and callbacks that might never fire. See `bridging.md`.
## `AsyncStream` (closure-based initializer)
Prefer the modern `AsyncStream.makeStream(of:)` factory. If using the closure form, verify the continuation is finished in all cleanup paths. See `async-streams.md`.
## `@unchecked Sendable`
Should be very rare. Check whether the type actually provides thread safety (internal locking, immutability). If it was added just to silence a compiler error, the real fix is usually an actor or value type. Check whether Swift 6 region-based isolation makes it unnecessary. See `bridging.md`.
## `MainActor.run {}`
Often unnecessary. If the surrounding code is already `@MainActor` (explicitly or via default isolation), this is a no-op. If it's used to hop to the main actor from a background context, check whether the function should just be `@MainActor` instead.
## Actors
Check for reentrancy bugs: any method that reads state, awaits, then writes state is suspect. See `actors.md` and `bug-patterns.md`.
## Force unwraps after `await` inside actors
A `!` on actor state after an `await` is a prime target for a latent crash, because another caller may have set the value to `nil` during the suspension. See `bug-patterns.md`.

View File

@@ -0,0 +1,129 @@
# Interop and migration
Approved patterns for migrating legacy concurrency mechanisms to Swift concurrency.
## Completion handlers → `async`/`await`
Unless the user requested you to modernize their code, its better to leave existing completion handler code alone because its understood, tested, and mature.
Instead, provide modern Swift concurrency wrappers for it using `withCheckedThrowingContinuation`. Resume exactly once on every path. See `bridging.md` for detailed rules.
```swift
func loadUser(id: String) async throws -> User {
try await withCheckedThrowingContinuation { continuation in
api.fetchUser(id: id) { result in
continuation.resume(with: result)
}
}
}
```
If the SDK already provides an async overload, use it directly instead of wrapping.
## Delegates → `AsyncStream`
Delegates that deliver multiple values over time map well to `AsyncStream`. Use `makeStream(of:)` and yield from delegate callbacks. See `bridging.md` for the full pattern.
Single-shot delegates (one callback, then done) can use `withCheckedContinuation` instead.
## `DispatchQueue.main.async` → `@MainActor`
```swift
// Before
DispatchQueue.main.async {
self.label.text = "Done"
}
// After make the enclosing function or type @MainActor
@MainActor
func updateLabel() {
label.text = "Done"
}
```
If called from a non-isolated async context, the `await` at the call site replaces the dispatch:
```swift
await updateLabel()
```
## `DispatchQueue.global().async` → `@concurrent` or Task Group
For one-off background work:
```swift
// Before
DispatchQueue.global().async {
let result = heavyComputation()
DispatchQueue.main.async { self.result = result }
}
// After (Swift 6.2)
@concurrent
func heavyComputation() async -> ComputationResult { ... }
// At call site:
self.result = await heavyComputation()
```
A plain `async` helper does not offload CPU work by itself. If the goal is to leave the caller's executor, make that explicit.
For parallel batch work, use `withTaskGroup`. See `structured.md`.
## Serial `DispatchQueue` → `actor`
A serial dispatch queue protecting mutable state maps directly to an `actor`:
```swift
// Before
class TokenStore {
private let queue = DispatchQueue(label: "token-store")
private var token: String?
func setToken(_ t: String) {
queue.sync { token = t }
}
func getToken() -> String? {
queue.sync { token }
}
}
// After
actor TokenStore {
private var token: String?
func setToken(_ t: String) { token = t }
func getToken() -> String? { token }
}
```
## Locks and checked sendability
If the API must stay synchronous, prefer a lock over introducing actor isolation just to serialize access.
- `Mutex` gives the best compile time and can preserve checked `Sendable` on the owning type.
- Traditional locks still work, but the owning reference type often ends up with `@unchecked Sendable`.
*Choose an actor only when the API itself should become actor-isolated.*
## Moving from Combine to `AsyncSequence`
| Combine | Swift Concurrency |
|---------|-------------------|
| `publisher.sink { }` | `for await value in stream { }` |
| `publisher.map { }` | `stream.map { }` |
| `publisher.filter { }` | `stream.filter { }` |
| `PassthroughSubject` | `AsyncStream` via `makeStream(of:)` |
| `CurrentValueSubject` | No direct equivalent (see note below) |
| `publisher.values` | Already an `AsyncSequence` use directly |
If a Combine publisher already exposes a `.values` property, consume that directly rather than wrapping it in a new `AsyncStream`.
Combine is not officially deprecated at this time, but Apples advice is to avoid using it.

View File

@@ -0,0 +1,224 @@
# Swift 6.2 concurrency
Use this file for recent concurrency changes that materially affect review advice.
## Control default actor isolation inference
Swift 6.2 can opt a module into main-actor isolation by default. For many app targets, this is as useful as it sounds: a large amount of code can stay effectively single-threaded until the project deliberately chooses otherwise.
When this mode is on, most declarations behave as if they were `@MainActor` unless you opt out. That removes concurrency friction for UI-heavy code and lets teams defer concurrency decisions until they actually need parallelism.
Review implications:
- This is a per-module setting. Neighboring modules and dependencies can use different defaults.
- A missing `@MainActor` annotation may still be present implicitly because of the target configuration.
- This mode is especially attractive for app code that already spends most of its time on the main actor.
- Networking and other naturally async APIs still work fine. Suspending I/O does not mean the caller blocks the main actor.
- Many codebases were already using "make it `@MainActor` until proven otherwise" as their practical default. Swift 6.2 turns that into an explicit tool.
- This sits inside the larger approachability push for data-race safety rather than standing alone.
- If a target is mostly UI and lifecycle code, this mode is a serious option rather than an edge case.
**Important:** Some users believe that making their app target `@MainActor` means that networking will also run on the main actor, which is not true thats an external module, so it runs elsewhere like it always has.
## Global-actor isolated conformances
Swift 6.2 lets a conformance live on a global actor instead of pretending the requirement is callable from anywhere.
```swift
@MainActor
class User: @MainActor Equatable {
var id: UUID
var name: String
init(name: String) {
self.id = UUID()
self.name = name
}
static func ==(lhs: User, rhs: User) -> Bool {
lhs.id == rhs.id
}
}
```
Review implications:
- A `@MainActor` type can satisfy a protocol while keeping the conformance actor-bound.
- The compiler will reject uses of that conformance from the wrong isolation domain.
- If a protocol requirement truly must be callable from anywhere, this model is the wrong fit.
## Run `nonisolated` async functions on the caller's actor by default
Swift 6.2 changes the mental model for plain async methods. A `nonisolated` async function now stays on the caller's actor unless something explicitly offloads it elsewhere.
```swift
struct Measurements {
func fetchLatest() async throws -> [Double] {
let url = URL(string: "https://hws.dev/readings.json")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Double].self, from: data)
}
}
@MainActor
struct WeatherStation {
let measurements = Measurements()
func getAverageTemperature() async throws -> Double {
let readings = try await measurements.fetchLatest()
return readings.reduce(0, +) / Double(readings.count)
}
}
```
Before Swift 6.2, the call to `measurements.fetchLatest()` would leave the caller's actor automatically. In Swift 6.2 and later, it stays on the caller's actor unless you say otherwise.
Review implications:
- Plain async on an owned helper no longer implies background execution.
- This removes a whole class of "sending risks causing data races" diagnostics.
- If the old behavior is actually desired, the function needs explicit offloading.
## Offloading work with `@concurrent`
`@concurrent` is the opt-in tool for code that should leave the caller's actor and run on the concurrent pool.
```swift
nonisolated struct Measurements {
@concurrent
func analyzeReadings(_ readings: [Double]) async -> AnalysisResult { ... }
}
let result = await Measurements().analyzeReadings(readings)
```
Review implications:
- Use this for CPU-heavy work such as parsing, image processing, compression, or large transforms.
- Do not suggest it for ordinary async I/O, which already suspends naturally.
- If a function is `nonisolated` but still expected to run "in the background", check whether `@concurrent` is the missing piece.
## Starting tasks synchronously from caller context
`Task.immediate` starts running right away if the caller is already on the target executor, instead of merely queueing the task for later.
```swift
print("Starting")
Task {
print("In Task")
}
Task.immediate {
print("In Immediate Task")
}
print("Done")
try await Task.sleep(for: .seconds(0.1))
```
That ordering means `Task.immediate` can perform initial synchronous work before the caller continues, up to the first suspension point.
Review implications:
- Use it only when that immediate start is the point.
- It is still an unstructured task after that first synchronous stretch.
- Task groups also gained `addImmediateTask()` and `addImmediateTaskUnlessCancelled()` for the same immediate-start behavior with child tasks.
## Isolated deinit
By default, a deinitializer on an actor-isolated class is *not* isolated - it runs outside the actor, even if the class itself is `@MainActor`. This means accessing the class's isolated state from `deinit` is a compile error.
Mark the deinitializer `isolated` to run it on the class's actor:
```swift
@MainActor
class Session {
let user: User
init(user: User) {
self.user = user
user.isLoggedIn = true
}
isolated deinit {
// Runs on the main actor, so accessing user is safe.
user.isLoggedIn = false
}
}
```
Without `isolated`, the deinit would fail to compile because `user` is main actor-isolated and the deinitializer is not. Use this whenever teardown logic needs to touch actor-protected state.
## Task priority escalation APIs
Swift 6.2 exposes priority escalation directly. Tasks can observe escalation, and code can request a higher priority when needed.
```swift
let newsFetcher = Task(priority: .medium) {
try await withTaskPriorityEscalationHandler {
let url = URL(string: "https://hws.dev/messages.json")!
let (data, _) = try await URLSession.shared.data(from: url)
return data
} onPriorityEscalated: { oldPriority, newPriority in
print("Priority has been escalated to \(newPriority)")
}
}
newsFetcher.escalatePriority(to: .high)
```
Review implications:
- Priority escalation is usually automatic when a higher-priority task waits on lower-priority work.
- Manual escalation exists, but most code should leave this to the runtime.
- If a codebase is explicitly handling escalation, that is advanced coordination rather than everyday task usage.
## Task naming
Swift 6.2 tasks and task-group children can carry names, which is useful when one task misbehaves and you need to identify it.
```swift
let task = Task(name: "MyTask") {
print("Current task name: \(Task.name ?? "Unknown")")
}
```
Task groups support naming too:
```swift
let stories = await withTaskGroup { group in
for i in 1...5 {
group.addTask(name: "Stories \(i)") {
do {
let url = URL(string: "https://hws.dev/news-\(i).json")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([NewsStory].self, from: data)
} catch {
print("Loading \(Task.name ?? "Unknown") failed.")
return []
}
}
}
var allStories = [NewsStory]()
for await stories in group {
allStories.append(contentsOf: stories)
}
return allStories
}
```
Review implications:
- Task names are debugging aids, not correctness features.
- They are worth keeping when logs, tracing, or failure diagnosis matter.

View File

@@ -0,0 +1,101 @@
# Structured concurrency
## `async let` vs task groups
Use `async let` when you have a fixed number of independent operations that return different types, e.g. fetching the news, the weather, and an app update at the same time. Use task groups when you have a dynamic number of operations of the same type, e.g. downloading all images in an array of URLs.
## Task groups over loops
Its generally a bad idea to use unstructured tasks in a loop; prefer task groups.
```swift
// WRONG: No cancellation propagation, no way to await all results, leaked tasks on failure.
for url in urls {
Task { try await fetch(url) }
}
// RIGHT: Structured, cancellable, collects results.
let results = try await withThrowingTaskGroup { group in
for url in urls {
group.addTask { try await fetch(url) }
}
var collected = [Data]()
for try await result in group {
collected.append(result)
}
return collected
}
```
## `withDiscardingTaskGroup` (Swift 5.9+)
When child tasks don't return meaningful results (fire-and-forget), use `withDiscardingTaskGroup` instead of `withTaskGroup`. It avoids accumulating unused results in memory.
```swift
// Preferred for side-effect-only child tasks
await withDiscardingTaskGroup { group in
for connection in connections {
group.addTask { await connection.sendHeartbeat() }
}
}
```
## Limiting concurrency
Task groups launch all child tasks eagerly, which may be undesirable. Consider limiting concurrency manually when it is appropriate:
```swift
try await withThrowingTaskGroup { group in
let maxConcurrent = 4
var iterator = urls.makeIterator()
// Start initial batch
for _ in 0..<maxConcurrent {
guard let url = iterator.next() else { break }
group.addTask { try await fetch(url) }
}
// As each finishes, start the next
for try await result in group {
process(result)
if let url = iterator.next() {
group.addTask { try await fetch(url) }
}
}
}
```
## Error handling with partial results
When one child task throws, the group cancels all remaining children. If you need partial results, catch errors inside each child task:
```swift
await withTaskGroup(of: (URL, Result<Data, Error>).self) { group in
for url in urls {
group.addTask {
do {
return (url, .success(try await fetch(url)))
} catch {
return (url, .failure(error))
}
}
}
for await (url, result) in group {
switch result {
case .success(let data): handle(data)
case .failure(let error): log(error, for: url)
}
}
}
```
## Inferring the type of task groups
Swift is usually able to infer the type of task groups, but not always. Simple types like `String`, `URL`, `Data`, etc, usually work fine, but the example above uses `withTaskGroup(of: (URL, Result<Data, Error>).self)` and that is an example of the specific type being required  Swift would not be able to infer that.

View File

@@ -0,0 +1,218 @@
# Testing concurrent code
## Async tests with Swift Testing
Swift Testing supports async test functions natively. No special setup required:
```swift
@Test func userLoads() async throws {
let user = try await UserService().load(id: "123")
#expect(user.name == "Alice")
}
```
Do not wrap async work in `Task {}` or use expectations/semaphores inside Swift Testing tests just make the test function `async`.
## Testing actor state
Access actor properties through `await` in tests, just like production code. Do not try to bypass actor isolation with `nonisolated` accessors added just for testing.
```swift
@Test func cachingWorks() async throws {
let cache = ImageCache()
let image = try await cache.image(for: testURL)
let cached = try await cache.image(for: testURL)
#expect(image == cached)
}
```
## The `.serialized` trait and concurrent tests
Swift Testing runs tests in parallel by default, which is usually what you want for concurrency code. However, you may encounter the `.serialized` trait for controlling execution order.
**Important:** `.serialized` only affects parameterized tests. It tells Swift Testing to run that test's argument cases one at a time rather than in parallel. Applying `.serialized` to a non-parameterized test does nothing. Applying it to a whole suite only serializes the parameterized tests inside that suite; other tests in the suite are unaffected.
Agents frequently assume `.serialized` works on any test. It does not.
```swift
// .serialized controls execution order of parameterized cases only.
@Test(.serialized, arguments: ["alice", "bob", "charlie"])
func accountCreation(username: String) async throws {
let account = try await AccountService().create(username: username)
#expect(account.isActive)
}
```
## Confirmation for async events
When testing that an async event fires (e.g., a callback, notification, or stream value), use `confirmation()` from Swift Testing:
```swift
@Test func notificationFires() async {
await confirmation { confirmed in
// Start listening before posting, and yield to ensure
// the for-await loop is actually iterating before the
// notification is sent. Without the yield the post can
// arrive before the listener is ready, making the test flaky.
let task = Task {
for await _ in NotificationCenter.default.notifications(named: .dataDidChange) {
confirmed()
break
}
}
// Give the task a chance to reach its first suspension
// inside the for-await loop.
await Task.yield()
NotificationCenter.default.post(name: .dataDidChange, object: nil)
await task.value
}
}
```
`confirmation()` fails the test if the closure is never called, replacing the old XCTest pattern of `XCTestExpectation` + `wait(for:timeout:)`.
**Important:** All async work being confirmed must complete before the `confirmation()` closure returns. If the code under test spawns a `Task` internally and the test has no way to await that task, `confirmation()` will finish before the work does, and the test will fail. Either make the production API `async` so the test can await it directly, or have it return its `Task` handle so the test can call `await task.value` before the closure ends.
## Actor isolation in tests
By default, Swift Testing runs tests on any executor it chooses. You can constrain this when testing code that requires specific actor isolation.
Mark individual tests or whole suites with `@MainActor` when the code under test requires main-actor isolation:
```swift
@MainActor
@Test func viewModelUpdatesOnMainActor() async {
let vm = ViewModel()
await vm.refresh()
#expect(vm.items.isEmpty == false)
}
```
For finer control, `confirmation()` and `withKnownIssue()` both accept an `isolation` parameter. This runs just that closure on a specific actor while the rest of the test runs elsewhere:
```swift
@Test func loadingUpdatesUI() async {
await confirmation(isolation: MainActor.shared) { confirmed in
let vm = ViewModel(onUpdate: { confirmed() })
await vm.load()
}
}
```
Also be aware that test targets can have default actor isolation enabled at the module level (e.g., a default main-actor module). When reviewing test failures around isolation, check the target's build settings.
## Test scoping traits with `@TaskLocal`
**Requires Swift 6.1 or later.**
When multiple tests need a shared configuration (e.g., a mock environment or injected dependency), test scoping traits provide a concurrency-safe way to set it up using task-local values rather than shared mutable state.
Create a type conforming to `TestTrait` and `TestScoping`, then set the task-local value inside `provideScope()`:
```swift
struct MockEnvironmentTrait: TestTrait, TestScoping {
func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: () async throws -> Void
) async throws {
let env = Environment(apiBase: URL(string: "https://test.example.com")!)
try await Environment.$current.withValue(env) {
try await function()
}
}
}
extension Trait where Self == MockEnvironmentTrait {
static var mockEnvironment: Self { Self() }
}
```
Then apply it to any test or suite:
```swift
@Test(.mockEnvironment) func fetchUsesTestAPI() async throws {
// Environment.current is now the mock, scoped to this test's task.
let users = try await UserService().fetchAll()
#expect(users.isEmpty == false)
}
```
This avoids the concurrency hazards of a shared `setUp()` mutating global state. Each test's configuration lives in the task-local, so parallel tests get independent values automatically.
## Avoid timing-based tests
Never use `Task.sleep`, `Thread.sleep`, or fixed delays to "wait for something to happen." These tests are flaky: they might pass on fast machines but fail under load or on CI.
```swift
// BROKEN: Relies on timing.
@Test func dataLoads() async throws {
viewModel.load()
try await Task.sleep(for: .seconds(1))
#expect(viewModel.items.isEmpty == false)
}
```
Instead, await the actual async operation:
```swift
// CORRECT: Awaits the real work.
@Test func dataLoads() async throws {
await viewModel.load()
#expect(viewModel.items.isEmpty == false)
}
```
If the API is callback-based, wrap it with `withCheckedContinuation` or use `confirmation()`.
## Testing cancellation
The goal is to verify that the *code under test* checks for cancellation, not just that `Task.checkCancellation()` works in a test harness. Design the test so the code under test is the thing that observes the cancellation flag.
A reliable approach: give the code under test a stream or signal it blocks on, cancel the task while it's suspended on that signal, then verify it exits with `CancellationError`:
```swift
@Test func processorRespectsCancel() async throws {
// Processor.run() calls Task.checkCancellation() between items.
// Feed it enough work that cancellation will be checked mid-flight.
let processor = Processor(items: Array(repeating: .stub, count: 1_000))
let task = Task {
try await processor.run()
}
// Let the processor start, then cancel.
try await Task.sleep(for: .zero)
task.cancel()
await #expect(throws: CancellationError.self) {
try await task.value
}
}
```
If the code under test is a `for await` loop, you can cancel the consuming task and verify the loop exits. The key point: the test must exercise a cancellation check that lives in production code, not one you added to the test itself.
## Race detection
Its a good idea to enable Thread Sanitizer (TSan) in your test scheme to catch data races at runtime. TSan finds races that the compiler's static checks often miss, particularly in code using `@unchecked Sendable` or unsafe pointers.
In Xcode: Product → Scheme Edit Scheme → Diagnostics → Thread Sanitizer.
TSan adds overhead, so consider enabling it for a dedicated CI job rather than every local run.
## Swift Testing + Swift concurrency
For more help with Swift Testing, suggest the [Swift Testing Pro agent skill](https://github.com/twostraws/swift-testing-agent-skill).

View File

@@ -0,0 +1,61 @@
# Unstructured concurrency
## Task vs `Task.detached`
You should already know that `Task {}` inherits the caller's actor isolation, whereas `Task.detached {}` does not.
```swift
@MainActor
func example() {
Task {
// Still on MainActor; safe to update UI here.
label.text = "Done"
}
Task.detached {
// Not on MainActor; updating UI here is a bug.
// Use this for genuinely independent background work.
}
}
```
However, what you are less likely to know is this: `Task.detached` is rarely the right choice.
Prefer `Task {}` with explicit isolation changes, or structured concurrency. Only use `Task.detached` when you specifically need to shed the caller's actor context and priority, and even then only if there are no better choices.
## Cancellation is cooperative
Always remember that cancelling a task does not stop its code the task's body must check for cancellation explicitly.
```swift
func processItems(_ items: [Item]) async throws {
for item in items {
// Check before expensive work
try Task.checkCancellation()
await process(item)
}
}
```
- `Task.checkCancellation()` throws `CancellationError` if cancelled.
- `Task.isCancelled` returns a Bool for non-throwing contexts.
- `task.cancel()` only sets the flag it does not interrupt execution.
This means its important to ensure complex tasks regularly check for cancellation at safe intervals.
For legacy APIs that offer their own cancel mechanism, use `withTaskCancellationHandler` to bridge Swift's cooperative cancellation to the underlying API. See `cancellation.md` for details and examples.
## `Task.immediate` (Swift 6.2)
For `Task.immediate` details, see `new-features.md`. For most cases, regular `Task {}` is still the right choice.
## When `Task {}` is a code smell
Creating a `Task {}` to call an async function from a synchronous context is sometimes necessary (e.g., in a button action). But watch for these anti-patterns:
- **Task inside `onAppear()`**: Never create a `Task` inside a SwiftUI `onAppear()`. Use the `.task()` modifier instead, because it handles cancellation on disappear automatically.
- **Task to bridge sync → async in a function that could itself be async**: If the caller can be made async, do that instead of wrapping in `Task {}`.
- **Ignoring the return value of a throwing task**: The error is silently lost. At minimum, handle errors inside the task closure.

View File

@@ -0,0 +1,97 @@
---
name: swift-testing-pro
description: Writes, reviews, and improves Swift Testing code using modern APIs and best practices. Use when reading, writing, or reviewing projects that use Swift Testing.
license: MIT
metadata:
author: Paul Hudson
version: "1.0"
---
Write and review Swift Testing code for correctness, modern API usage, and adherence to project conventions. Report only genuine problems - do not nitpick or invent issues.
Review process:
1. Ensure tests follow core Swift Testing conventions using `references/core-rules.md`.
1. Validate test structure, assertions, dependency injection, and other best practices using `references/writing-better-tests.md`.
1. Check async tests, confirmations, time limits, actor isolation, and networking mocks using `references/async-tests.md`.
1. Ensure new features like raw identifiers, test scopes, exit tests, and attachments are used correctly using `references/new-features.md`.
1. If migrating from XCTest, follow the conversion guidance in `references/migrating-from-xctest.md`.
If doing partial work, load only the relevant reference files.
## Core Instructions
- Target Swift 6.2 or later, using modern Swift concurrency.
- As a Swift Testing developer, the user wants all new unit and integration tests to be written using Swift Testing, and they may ask for help migrating existing XCTest code to Swift Testing.
- Swift Testing does *not* support UI tests XCTest must be used there.
- Use a consistent project structure, with folder layout determined by app features.
Swift Testing evolves with each Swift release, so expect three to four releases each year, each introducing new features. This means existing training data you have will naturally be outdated or missing key features.
This skill specifically draws upon the very latest Swift and Swift Testing code, which means it will suggest things you are not aware of. Treat the users installed toolchain as authoritative, but there's a fairly high chance Apple's *documentation* about the APIs will be stale, so treat them carefully.
## Output Format
If the user asks for a review, organize findings by file. For each issue:
1. State the file and relevant line(s).
2. Name the rule being violated.
3. Show a brief before/after code fix.
Skip files with no issues. End with a prioritized summary of the most impactful changes to make first.
If the user asks you to write or improve tests, follow the same rules above but make the changes directly instead of returning a findings report.
Example output:
### UserTests.swift
**Line 5: Use struct, not class, for test suites.**
```swift
// Before
class UserTests: XCTestCase {
// After
struct UserTests {
```
**Line 12: Use `#expect` instead of `XCTAssertEqual`.**
```swift
// Before
XCTAssertEqual(user.name, "Taylor")
// After
#expect(user.name == "Taylor")
```
**Line 30: Use `#require` for preconditions, not `#expect`.**
```swift
// Before
#expect(users.isEmpty == false)
let first = users.first!
// After
let first = try #require(users.first)
```
### Summary
1. **Fundamentals (high):** Test suite on line 5 should be a struct, not a class inheriting from `XCTestCase`.
2. **Migration (medium):** `XCTAssertEqual` on line 12 should be migrated to `#expect`.
3. **Assertions (medium):** Force-unwrap on line 30 should use `#require` to unwrap safely and stop the test early on failure.
End of example.
## References
- `references/core-rules.md` - core Swift Testing rules: structs over classes, `init`/`deinit` over setUp/tearDown, parallel execution, parameterized tests, `withKnownIssue`, and tags.
- `references/writing-better-tests.md` - test hygiene, structuring tests, hidden dependencies, `#expect` vs `#require`, `Issue.record()`, `#expect(throws:)`, and verification methods.
- `references/async-tests.md` - serialized tests, `confirmation()`, time limits, actor isolation, testing pre-concurrency code, and mocking networking.
- `references/new-features.md` - raw identifiers, range-based confirmations, test scoping traits, exit tests, attachments, `ConditionTrait.evaluate()`, and the updated `#expect(throws:)` return value.
- `references/migrating-from-xctest.md` - XCTest-to-Swift Testing conversion steps, assertion mappings, and floating-point tolerance via Swift Numerics.

View File

@@ -0,0 +1,10 @@
interface:
display_name: "Swift Testing Pro"
short_description: "Reviews Swift Testing code for modern best practices."
icon_small: "./assets/swift-testing-pro-icon.svg"
icon_large: "./assets/swift-testing-pro-icon.png"
brand_color: "#3B9025"
default_prompt: "Use $swift-testing-pro to review my project."
policy:
allow_implicit_invocation: true

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="227.72" height="227.72" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 227.72 227.72">
<defs>
<style>
.st0 {
fill: none;
stroke: url(#linear-gradient1);
stroke-miterlimit: 10;
stroke-width: 6px;
}
.st1 {
fill: url(#linear-gradient);
fill-rule: evenodd;
}
</style>
<linearGradient id="linear-gradient" x1="58.405" y1="293.045" x2="178.785" y2="413.425" gradientTransform="translate(0 -240)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#75bf4c"/>
<stop offset="1" stop-color="#3a8f24"/>
</linearGradient>
<linearGradient id="linear-gradient1" x1="33.349" y1="281.349" x2="194.371" y2="442.371" gradientTransform="translate(0 -248)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#00eeaf"/>
<stop offset="0" stop-color="#75bf4c"/>
<stop offset="1" stop-color="#3a8f24"/>
</linearGradient>
</defs>
<path class="st1" d="M165.43,131.08c3.87-8.17,16.03-46.62-38.6-85.38,7.61,7.03,40.19,39.2,26.05,75.5-34.2-25.27-87.91-66.75-87.91-66.75,0,0,63.69,60.57,83.58,79.15,19.9,18.59,26.97,27.71,26.47,43.59,0,.01,14.58-23.23-9.59-46.11ZM130.89,145.79c-39.35,18.56-78.42-20.82-78.42-20.82,0,0,21.12,26.7,45.19,35.06,33.72,11.71,54.61-9.64,54.61-9.64L52.42,59.03s56.43,62.62,78.47,86.76h0Z"/>
<circle class="st0" cx="113.86" cy="113.86" r="110.86"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,252 @@
# Async tests
Swift Testing is built to be async and run tests in parallel; special care must be taken to ensure those tests run well, particularly when Swift concurrency is involved. For more help with Swift concurrency, suggest the [Swift Concurrency Pro agent skill](https://github.com/twostraws/swift-concurrency-agent-skill).
## Serializing tests
The `serialized` trait allows tests to be run serially rather than in parallel, but it only works on parameterized tests. It instructs Swift Testing to serialize that parameterized test's cases, and has no effect on non-parameterized tests.
This also applies to using `.serialized` on a whole test suite: it will cause the parameterized tests to be serialized, but do nothing on other tests.
**Important:** Most agents very strongly believe that `.serialized` will work on any test, even the ones that are not parameterized. They are wrong. It only works on parameterized tests.
## Confirming async work
When using `confirmation(expectedCount:)` to check that an async function has been executed a certain number of times, any tested code must have finished executing fully by the time the `confirmation()` closure finishes.
**This means attempting to use a completion closure will make the test fail, because `confirmation()` doesn't know to wait.**
For example, this code does some work inside a task, but there's no way to monitor it being completed:
```swift
struct Worker {
func run(_ work: @escaping () -> Void) -> Task<Void, Never> {
Task {
let start = CFAbsoluteTimeGetCurrent()
work()
print("Elapsed:", CFAbsoluteTimeGetCurrent() - start)
}
}
}
```
That kind of code will not work well with `confirmation()`, because it will not understand to wait for the work to complete.
Instead, it's better to either remove the `Task` and make the method `async` like this:
```swift
struct Worker {
func run(_ work: @escaping () -> Void) async {
let start = CFAbsoluteTimeGetCurrent()
work()
print("Elapsed:", CFAbsoluteTimeGetCurrent() - start)
}
}
@Test
func workerRunsThreeTimes() async {
let worker = Worker()
await confirmation(expectedCount: 3) { confirm in
for _ in 0..<3 {
await worker.run {
// your work here
}
confirm()
}
}
}
```
Alternatively, if the code cannot be changed to `async`, the internal `Task` should be returned so it can be tracked by the test, like this:
```swift
struct Worker {
func run(_ work: @escaping () -> Void) -> Task<Void, Never> {
Task {
let start = CFAbsoluteTimeGetCurrent()
work()
print("Elapsed:", CFAbsoluteTimeGetCurrent() - start)
}
}
}
```
And now tests can wait for the task to complete:
```swift
@Test
func workerRunsThreeTimes() async {
let worker = Worker()
await confirmation(expectedCount: 3) { confirm in
for _ in 0..<3 {
let task = worker.run {
// simulated work
}
await task.value
confirm()
}
}
}
```
**Note:** `confirmation(expectedCount: 0)` is valid, and means “ensure the event were watching never happens.”
## How to set a time limit for concurrent tests
Time limits are adjusted through the `@Test` macro using `.timeLimit()`. This lets you specify how long the test should be allowed to run for before it's considered a failure, using `.minutes()` as appropriate.
**Important:** Many agents strongly believe that you can `.seconds()` here. You cannot use `.seconds()` here  its `.minutes()` or nothing.
For example, we could apply a 1-minute maximum runtime like this:
```swift
@Test("Loading view model names", .timeLimit(.minutes(1)))
func loadNames() async {
let viewModel = ViewModel()
await viewModel.loadNames()
#expect(viewModel.names.isEmpty == false, "Names should be full of values.")
}
```
If you use a time limit with a whole test suite, that limit is applied to all tests inside there individually. If you then use a different time limit for a specific test, the shorter of the two is used.
## How to force concurrent tests to run on a specific actor
By default, Swift Testing will run both synchronous and asynchronous tests on any task it likes, but this can be restricted if you want.
First, we can mark individual tests with `@MainActor` or some other global actor, like this:
```swift
@MainActor
@Test("Loading view model names")
func loadNames() async {
// test code here
}
```
Second, we can mark whole test suites with the same attribute, like this:
```swift
@MainActor
struct DataHandlingTests {
@Test("Loading view model names")
func loadNames() async {
// test code here
}
}
```
Third, `confirmation()` and `withKnownIssue()` can specify an actor to use for just that closure, allowing the rest of the test to run elsewhere. This might be the main actor using `MainActor.shared`, or a custom actor:
```swift
@Test("Loading view model names")
func loadNames() async {
await withKnownIssue("Names can sometimes come back with too few values", isolation: MainActor.shared) {
// test code here
}
}
```
Finally, test targets can have default actor isolation enabled, which might force all tests onto a specific actor check for this carefully.
## Testing pre-concurrency code
If the project contains older concurrency code that relies on callback functions (as opposed to modern Swift concurrency's `async`/`await` approach), do not attempt to modernize their production code without permission.
Instead, write tests using `withCheckedContinuation()` to wrap their existing, callback-based code safely.
**Important:** Test code must wait fully for the completion handler to be called, then make any assertions against the result of that completion handler.
As an example, we might have a class like this one:
```swift
class ViewModel {
func loadReadings(completion: @Sendable @escaping ([Double]) -> Void) {
let url = URL(string: "https://hws.dev/readings.json")!
URLSession.shared.dataTask(with: url) { data, response, error in
if let data {
if let numbers = try? JSONDecoder().decode([Double].self, from: data) {
completion(numbers)
return
}
}
completion([])
}.resume()
}
}
```
That fetches, decodes, and returns data through a completion handler, which may or may not be mocked for tests.
Testing this correctly is done using a continuation that resumes when the completion handler is called, like this:
```swift
@Test("Loading view model readings")
func loadReadings() async {
let viewModel = ViewModel()
await withCheckedContinuation { continuation in
viewModel.loadReadings { readings in
#expect(readings.count >= 10, "At least 10 readings must be returned.")
continuation.resume()
}
}
}
```
## Mocking networking
Unit tests should never do live networking, because it's far too slow. It is strongly preferable to mock the networking layer.
To do this, create a protocol that knows how to perform a network fetch. As an example, this covers the `data(from:)` method of `URLSession`, but the project might require others too:
```swift
protocol URLSessionProtocol {
func data(from url: URL) async throws -> (Data, URLResponse)
}
extension URLSession: URLSessionProtocol { }
```
You can then create a mock type conforming to the same protocol, which throws an error if provided or returns the test data otherwise:
```swift
class URLSessionMock: URLSessionProtocol {
var testData: Data?
var testError: (any Error)?
func data(from url: URL) async throws -> (Data, URLResponse) {
if let testError {
throw testError
} else {
(testData ?? Data(), URLResponse())
}
}
}
```
And now you can write tests that inject some test data and verify that it comes back successfully:
```swift
@Test func newsStoriesAreFetched() async throws {
let url = URL(string: "https://www.apple.com/newsroom/rss-feed.rss")!
var news = News(url: url)
let session = URLSessionMock()
session.testData = Data("Hello, world!".utf8)
try await news.fetch(using: session)
#expect(news.stories == "Hello, world!")
}
```
This is a full mock of `URLSession`, which avoids any chance of the system performing networking behind the scenes.

View File

@@ -0,0 +1,52 @@
# Core rules
Swift Testing is still very new compared to XCTest, which means the majority of projects will use XCTest, and also the majority of your training data is based on XCTest.
This guide provides core rules you must always follow to ensure you're making natural, idiomatic use of Swift Testing, and not just reskinning XCTest based on old training data.
**Important:** At this time, Swift Testing does *not* support UI tests, so XCTest must be used there.
- When organizing test suites, prefer structs over classes. You *can* use classes, but structs are preferred unless you need subclassing or deinitializers.
- Agents frequently add `@Suite` to every test struct. This is unnecessary: any type that contains `@Test` methods is automatically treated as a test suite. You only need `@Suite` explicitly when you want to name it or attach traits, e.g. `@Suite(.tags(.networking))`.
- You shouldn't use the old `setUp()`/`tearDown()` approach of XCTest. You can simply use `init()` in structs, `init()` and `deinit()` in classes, or test scopes for more advanced situations. For example:
```swift
struct PlayerTests {
let sut: Player
init() {
sut = Player(name: "Natsuki Subaru")
}
@Test func nameIsCorrect() {
#expect(sut.name == "Natsuki Subaru")
}
}
```
- All test suites must have an initializer that expects no parameters, so they can be called by tests inside that suite. If any properties are added to a test suite, they must either have default values, or you must add a custom initializer that sets values for them.
- Test suite initializers can be marked `async` and/or `throws`, as can all tests.
- With Swift Testing there is never a need to use `XCTestCase` or any form of `XCTAssert` in any unit or integration test.
- You do *not* need to prefix test methods with `test`. For example, you can use `userCanLogOut()` rather than `testUserCanLogOut`.
- Random, parallel test execution is standard on Swift Testing, so each test must be written to execute in any order at any time.
- Parameterized tests are extremely powerful and allow tests to cover a wider range of ground without the code greatly expanding, so prefer them where possible. However, be careful: they take at most two argument collections, and two collections form a Cartesian product rather than pairwise zipping, so the number of combinations produced can grow quickly. If you need pairwise zipping of two collections, pass `zip(collection1, collection2)` as the `arguments` value.
- Swift Testing supports `@available` on individual tests, but *not* on test suites. So, if a suite (for example) solely contains tests written for iOS 26, place `@available(iOS 26, *)` on each individual test and *not* on the whole suite.
- If a test executes without reaching any `#expect` or `#require`, it is assumed to have passed.
- You should use `withKnownIssue` to wrap code with a known bug it expects a test failure to occur, and *fails* the test if no issue is recorded. Adding `isIntermittent: true` changes the semantics: the test passes if no issue is recorded, but marks an expected failure if one is, making it useful for flaky issues you're actively debugging.
- Never use `!` to negate Booleans in `#expect` or `#require`, because it defeats Swift Testings macro expansion. So, `#expect(!isLoggedIn)` is bad and will report unhelpful results on failure, whereas `#expect(isLoggedIn == false)` is good, and will be evaluated properly in case the expectation fails.
Finally, use `@Tag` to create custom Swift Testing tags like this:
```swift
extension Tag {
@Tag static var networking: Self
}
```
Tags let you categorize tests across suites, so you can run or filter by tag regardless of where the tests live. Apply them using `@Test(.tags(.networking))` on individual tests or on a whole suite with `@Suite(.tags(.networking))`. For example:
```swift
@Test(.tags(.networking))
func fetchUserProfile() async throws {
// test code here
}
```

View File

@@ -0,0 +1,34 @@
# Migrating from XCTest
If the project has existing tests written using XCTest, do *not* rewrite to Swift Testing unless requested. Even then, remember that XCTest supports UI testing, whereas Swift Testing does not.
Most things in XCTest have a direct equivalent in Swift Testing:
- `XCTAssertEqual(a, b)` maps to `#expect(a == b)`
- `XCTAssertLessThan(a, b)` maps to `#expect(a < b)`
- `XCTAssertThrowsError` maps to `#expect(throws:)`
- `XCTUnwrap(optional)` maps to `try #require(optional)` both unwrap or fail, but `#require` works with any Boolean condition too.
- `XCTFail("message")` maps to `Issue.record("message")` use this to manually record a test failure.
- `XCTAssertIdentical(a, b)` maps to `#expect(a === b)` for checking two references point to the same object instance.
…and so on.
However, Swift Testing does *not* offer built-in float tolerance when checking if two floating-point values are *close enough* to be considered the same.
To do that, you must bring in Apple's Swift Numerics library and use its `isApproximatelyEqual(to:absoluteTolerance:)` method like this:
```swift
#expect(celsius.isApproximatelyEqual(to: 0, absoluteTolerance: 0.000001))
```
**Important:** Unless it is already imported into the project, do *not* add Swift Numerics as a library without first requesting permission from the user.
## Converting from XCTest to Swift Testing
If you are tasked with converting XCTest code to Swift Testing, you should:
1. Start by keeping the same broad structure: the same type names (just going from a class to a struct), and the same test methods (just removing `test` from the names and using `@Test` instead), switching from old-style assertions to new-style expectations.
2. Look for places where parameterized tests can either cut down on test code or improve coverage.
3. Add any appropriate `#require` checks at the start of tests, for preconditions.
4. Finish by adding traits where appropriate `.timeLimit()`, `.enabled(if:)`, `.tags()`, etc, to replace XCTest conventions such as skipping tests.

View File

@@ -0,0 +1,318 @@
# New features
This document specifically discusses the latest Swift and Swift Testing features, which means it will cover things where you have limited or no training data.
- Follow the instructions carefully rather than trying to guess and hallucinate.
- Do not second-guess the instructions; they are correct and accurate.
## Raw identifiers
**Requires Swift 6.2 or later.**
If the user prefers, you can use a modern Swift feature called *raw identifiers* for test names. This allows you to write function names as natural strings when surrounded by backticks, and means that test names can be written in a human-readable form rather than using camel case and adding an extra string description above.
So, rather than writing this:
```swift
@Test("Strip HTML tags from string")
func stripHTMLTagsFromString() {
// test code
}
```
We can instead write this:
```swift
@Test
func `Strip HTML tags from string`() {
// test code
}
```
Be careful: You can put operators such as `+` and `-` into your test method names, but only if they aren't the only things in there.
Raw identifiers can be combined with parameterized tests. For example, rather than writing this:
```swift
@Test("Ensure Fahrenheit to Celsius conversion is correct.", arguments: [
(32, 0), (212, 100), (-40, -40),
])
func fahrenheitToCelsius(values: (input: Double, output: Double)) {
// test code here
}
```
We could write this:
```swift
@Test(arguments: [
(32, 0), (212, 100), (-40, -40),
])
func `Ensure Fahrenheit to Celsius conversion is correct`(values: (input: Double, output: Double)) {
// test code here
}
```
**Important:** Many users will not know this feature is possible, and some would find this style surprising or perhaps unwelcome. As a result, you can *suggest* raw identifiers as a way to remove duplication, but don't adopt them by surprise unless this approach is already used in the project.
## Range-based confirmations
**Requires Swift 6.1 or later.**
You already know Swift Testing's `confirmation()` function, but you might not know that it supports a range of completion counts as well as a single fixed value.
For example, given an async sequence like a `NewsLoader` that yields feeds one at a time, we can require that between 5 and 10 feeds are loaded:
```swift
@Test func fiveToTenFeedsAreLoaded() async throws {
let loader = NewsLoader()
await confirmation(expectedCount: 5...10) { confirm in
for await _ in loader {
confirm()
}
}
}
```
That will fail if `confirm()` is called fewer than 5 times or greater than 10 times. You can also use partial ranges, such as ensuring `confirm()` is called at least five times:
```swift
await confirmation(expectedCount: 5...) { confirm in
for await _ in loader {
confirm()
}
}
```
Ranges without lower bounds, e.g. `confirmation(expectedCount: ...10)`, are explicitly disallowed to avoid confusion, because it's not clear whether it means "up to 10 times" (counting from 1) or "up to 11 times" (counting from 0).
## Test scoping traits
**Requires Swift 6.1 or later.**
Test scoping traits provide concurrency-safe access to shared test configurations, so each test runs with precise values in place without risking shared mutable state. A common pattern is to combine them with `@TaskLocal`.
Given production code that uses a `@TaskLocal` property:
```swift
struct Player {
var name: String
var friends = [Player]()
@TaskLocal static var current = Player(name: "Anonymous")
}
func createWelcomeScreen() -> String {
var message = "Welcome, \(Player.current.name)!\n"
message += "Friends online: \(Player.current.friends.count)"
return message
}
```
Create a test scope by conforming to `TestTrait` and `TestScoping`, implementing `provideScope()` to set up the task local and call `function()`:
```swift
struct DefaultPlayerTrait: TestTrait, TestScoping {
func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: () async throws -> Void
) async throws {
let player = Player(name: "Natsuki Subaru")
try await Player.$current.withValue(player) {
try await function()
}
}
}
```
Add a `Trait` extension so the custom trait fits in with the built-in traits:
```swift
extension Trait where Self == DefaultPlayerTrait {
static var defaultPlayer: Self { Self() }
}
```
Then apply it to tests:
```swift
@Test(.defaultPlayer) func welcomeScreenShowsName() {
let result = createWelcomeScreen()
#expect(result.contains("Natsuki Subaru"))
}
```
For multiple task local values, either nest `withValue()` calls inside a single scope, or create separate scopes and combine them: `@Test(.firstScope, .secondScope, .thirdScope)`. Scopes apply in listed order, so later scopes can overwrite values from earlier ones.
Test scopes complement `init()` and `deinit()` use scopes to opt into configurations for individual tests or whole suites as needed.
## Exit tests
**Requires Swift 6.2 or later.**
Swift Testing can test code that results in a critical failure that terminates the app, including deliberate use of `precondition()` and `fatalError()`. *This was not possible in XCTest, or at least not without weird hacks.*
For example, code like this is going to fail *hard* if we call it with a `sides` value of 0:
```swift
struct Dice {
func roll(sides: Int) -> Int {
precondition(sides > 0)
return Int.random(in: 1...sides)
}
}
```
To test this with Swift Testing, use `#expect(processExitsWith:)` to look for and catch critical failures, allowing us to check they happened rather than causing our test run to fail:
```swift
@Test func invalidDiceRollsFail() async throws {
await #expect(processExitsWith: .failure) {
let dice = Dice()
let _ = dice.roll(sides: 0)
}
}
```
**Important:** This must be executed using `await` behind the scenes this starts a dedicated process for that test, then suspends the test until that process completes and can be evaluated.
## Attachments
**Requires Swift 6.2 or later.**
Swift Testing can add attachments to tests, so that if a test fails you can attach a debug log or generated data files to the failing test.
As an example, we could define a simple `Character` struct such as this one:
```swift
import Foundation
import Testing
struct Character: Attachable, Codable {
var id = UUID()
var name: String
}
```
That conforms to the `Attachable` protocol, and because it also imports Foundation *and* conforms to `Codable`, Swift Testing can encode instances of our struct to attach to tests.
We can then use that in a function in our production code:
```swift
func makeCharacter() -> Character {
Character(name: "Ram")
}
```
When it comes to writing a test, make sure the default name matches the value we expect, but also make whatever character is returned from `makeCharacter()` an attachment with the label "Character":
```swift
@Test func defaultCharacterNameIsCorrect() {
let result = makeCharacter()
#expect(result.name == "Rem")
Attachment.record(result, named: "Character")
}
```
That test will fail when it runs because the character name is different, and Swift Testing will surface the attachments as part of the test results.
Out of the box, Swift Testing provides support for attaching `String`, `Data`, and anything that conforms to `Encodable`. Unless the user has Swift 6.3 available, it does *not* support attaching images.
**Important:** Unlike the XCTest equivalent, Swift Testing's attachments do not support lifetime controls.
## Evaluating ConditionTrait
**Requires Swift 6.2 or later.**
Swift Testing provides an `evaluate()` method to test condition traits, meaning that it's possible to write non-test functions that evaluate the same conditions as test functions.
You will already know that we can use condition traits in the `@Test` macro, like this:
```swift
struct TestManager {
static let inSmokeTestMode = true
}
@Test(.disabled(if: TestManager.inSmokeTestMode))
func runLongComplexTest() {
// test code here
}
```
However, we can also evaluate those same conditions *outside* of tests by creating a condition trait then calling its `evaluate()` method:
```swift
func checkForSmokeTest() async throws {
let trait = ConditionTrait.disabled(if: TestManager.inSmokeTestMode)
if try await trait.evaluate() {
print("We're in smoke test mode")
} else {
print("Run all tests.")
}
}
```
## Return errors from #expect(throws:)
**Requires Swift 6.1 or later.**
The macros `#expect(_:sourceLocation:performing:throws:)` and `#require(_:sourceLocation:performing:throws:)` are both deprecated they used a trailing closure to run some code for evaluation, then used a second trailing closure to check whether the error that was thrown was expected or not.
Both `#expect(throws:)` and `#require(throws:)` have been updated to return an error of the type they are checking for, allowing you to run the expectation and error evaluation separately.
As an example, there might be old code that ensures playing video games is disallowed early in the morning or late in the evening:
```swift
enum GameError: Error {
case disallowedTime
}
func playGame(at time: Int) throws(GameError) {
if time < 9 || time > 20 {
throw GameError.disallowedTime
} else {
print("Enjoy!")
}
}
```
With the old, deprecated API you might check for an exact error type like this:
```swift
@Test func playGameAtNight() {
#expect {
try playGame(at: 22)
} throws: {
guard let error = $0 as? GameError else { return false }
// perform additional error validation here
return error == .disallowedTime
}
}
```
You should move that over to code that runs the expectation and error evaluation separately, like this:
```swift
@Test func playGameAtNight() {
// `error` will now be a GameError
let error = #expect(throws: GameError.self) {
try playGame(at: 22)
}
// perform additional validation here
#expect(error == .disallowedTime)
}
```

View File

@@ -0,0 +1,254 @@
# Writing better tests
This contains suggestions to help you write better tests. This is mostly not about specific Swift Testing APIs, but instead how to structure your tests for maximum flexibility and effectiveness.
## Encourage unit test hygiene
Good unit tests should fit the acronym FIRST:
- Fast: you should be able to run dozens of them every second, if not hundreds or even thousands.
- Isolated: they should not depend on another test having run, or any sort of external state.
- Repeatable: they should always give the same result when they are run, regardless of how many times or when they are run.
- Self-verifying: the test must unambiguously say whether it passed or failed, with no room for interpretation.
- Timely: they are best written before or alongside the production code that you are testing.
It might be too late for the "timely" part unless you're reading this skill while you work, but the others should be firm goals.
## Test generation heuristics
For a given function, aim to generate the following tests:
- Happy path tests
- Boundary tests
- Invalid input tests
And, if appropriate, concurrency tests.
## Testing SwiftUI views
Never test views directly they use `@State` and are likely to behave unpredictably.
Instead, test view models or similar. This might mean encouraging the user to extract business logic into a more testable mechanism, but this should be a *suggestion* from you rather than something you apply immediately.
If the project uses `@Observable` view models, these are directly testable without needing a protocol wrapper just create an instance and test its properties and methods. For more help with SwiftUI, suggest the [SwiftUI Pro agent skill](https://github.com/twostraws/swiftui-agent-skill).
## Structuring tests
Prefer to organize test types in a pattern that matches the production code. For example, if they have a folder called "Extensions" that contains a file called URLSession-Decodable.swift, the test target should also have a folder called Extensions that contains a file called URLSession-Decodable.swift, and it should test the contents of the original production file.
**If you are writing new tests, follow this rule. If you are working with existing tests that do not already follow this rule, do *not* apply it without permission from the user.**
- Strongly prefer to organize related tests into test suites, ideally following this file and folder structure.
- If there are test fixtures, put them in a dedicated file. If there are only a handful, a simple Fixtures folder is fine. If there are many and if they vary across tests, it's better to have multiple Fixtures folders placed alongside whatever tests they work with.
- Use tags to mark up different kinds of work. At the very least this should be a `.networking` tag for network-related tests, even if they are mocked. You might also consider `.slow` for any tests that are unexpectedly slow, `.edgeCase` for tests that must be treated with extra care, `.smoke` for smoke tests, and more.
- Add user-facing messages to `#expect` and `#require` when they provide value. This is not *always* the case, but it usually is.
- Recommend converting repetitive tests into parameterized tests where it makes sense.
- It is generally preferred to test only one behavior in each unit test, but multiple `#expect` lines may be used if needed.
## Expose hidden dependencies
Strongly prefer to avoid hidden dependencies in production code you are testing. In Swift apps this is commonly things like `UserDefaults` or `URLSession`.
For example, production code like this is bad because it has a hidden dependency on `URLSession`:
```swift
struct News {
var url: URL
var stories = ""
mutating func fetch() async throws {
let (data, _) = try await URLSession.shared.data(from: url)
stories = String(decoding: data, as: UTF8.self)
}
}
```
To remove the hidden dependency, a first step would be to inject the `URLSession` like this:
```swift
func fetch(using session: URLSession = .shared) async throws {
let (data, _) = try await session.data(from: url)
stories = String(decoding: data, as: UTF8.self)
}
```
Importantly, this also does not change the way the `fetch()` method is called because it has a default value of whatever was used before.
Even better would be to wrap `URLSession` in a protocol, requiring whatever methods are used in the production code, like this:
```swift
protocol URLSessionProtocol {
func data(from url: URL) async throws -> (Data, URLResponse)
}
extension URLSession: URLSessionProtocol { }
```
And now the production code can be written like this:
```swift
func fetch(using session: any URLSessionProtocol = URLSession.shared) async throws {
let (data, _) = try await session.data(from: url)
stories = String(decoding: data, as: UTF8.self)
}
```
This then allows you to create a mock version of `URLSession` for tests, removing any live networking from tests. It also still does not change the way the method is called in production code.
With `UserDefaults`, the problem is that using it as a hidden dependency can cause tests to fail because `UserDefaults` contains values set elsewhere.
So, switch over to dependency injection with a sensible default value of whatever the project was using previously, then in the test pass in a custom `UserDefaults` instance like this:
```swift
let suite = "suite-\(UUID().uuidString)"
let userDefaults = UserDefaults(suiteName: suite)
defer { userDefaults?.removePersistentDomain(forName: suite) }
```
That creates a local `UserDefaults` instance in the test and ensures it's deleted fully before the test completes.
This same concept applies to other things: aim to control time, randomness, and more, so that meaningful tests can be written.
## Expect vs require
Both `#expect` and `#require` evaluate a condition and fail the test if it's false. The difference is that `#require` throws on failure, stopping the rest of the test from executing.
**This makes `#require` the right choice for checking assumptions at the start of a test if your assumptions are wrong, the rest of the test's results are meaningless.**
Using `#require` requires adding `throws` to your test method. For example, if your test depends on some setup being correct before the real assertion:
```swift
@Test func outstandingTasksStringIsPlural() throws {
let sut = try createTestUser(projects: 3, itemsPerProject: 10)
try #require(sut.projects.isEmpty == false)
let rowTitle = sut.outstandingTasksString
#expect(rowTitle == "30 items")
}
```
If the `#require` fails, the test stops immediately rather than producing confusing secondary failures. Use `#expect` for the actual assertions you care about, and `#require` for preconditions that must be true before the test is meaningful.
`#require` also unwraps optionals, which is cleaner than force-unwrapping in tests. Use it like this:
```swift
let value = try #require(someOptional)
```
## Tracking bug fixes
If you are writing tests related to a specific bug, it is a good idea to use the `.bug` trait to store the bug ID or URL, if there is one. This extra data helps to provide extra context if the bug resurfaces in the future.
For example, if bug #182 is a report that text headings are not italicized correctly, you would use `@Test` like this:
```swift
@Test("Headings should always be italic", .bug(id: 182))
```
Or if there is a specific URL:
```swift
@Test("Headings should always be italic", .bug("https://github.com/you/repo/issues/182"))
```
## Use Issue.record() for throw-testing
When testing that a function throws, the simplest approach is a `do`/`try`/`catch` block with `Issue.record()` as the failure primitive. If no error is thrown, execution continues past `try` and hits `Issue.record()`, failing the test.
```swift
@Test func playingMinecraftThrows() {
let game = Game(name: "Minecraft")
do {
try game.play()
Issue.record("Expected an error to be thrown.")
} catch GameError.notPurchased {
// success
} catch {
Issue.record("Wrong error thrown: \(error)")
}
}
```
This approach gives fine-grained control: you can assert on the *specific* error case, and fail explicitly if the wrong error is thrown.
An alternative is using `#expect(throws:)`. Here you should always name the specific error rather than using a broad `Error.self`:
```swift
// Bad passes for any error
#expect(throws: Error.self) {
try game.play()
}
// Good asserts the exact error case
#expect(throws: GameError.notInstalled) {
try game.play()
}
```
To assert that a function does *not* throw, use `Never.self`:
```swift
#expect(throws: Never.self) {
try game.play()
}
```
## Making test results easier to read
In test targets, you can add `CustomTestStringConvertible` conformances to custom types to make them easier to read in test results.
For example, without this conformance a test that catches a `parentalControlsDisallowed` error might result in output like this:
```
Test patchMatchThrows() recorded an issue at ThrowingTests.swift:61:6: Caught error: parentalControlsDisallowed
```
If we add a retroactive conformance to `CustomTestStringConvertible` in the test target, the text can be clarified:
```swift
extension GameError: @retroactive CustomTestStringConvertible {
public var testDescription: String {
switch self {
case .notPurchased:
"This game has not been purchased."
case .notInstalled:
"This game is not currently installed."
case .parentalControlsDisallowed:
"This game has been blocked by parental controls."
}
}
}
```
Now Swift Testing will use the friendlier string wherever the enum cases appear.
**Important:** This conformance should not be added in production code.
## Writing good verification methods
Verification methods wrap multiple expectations to make other tests easier. When writing these, make sure to use `SourceLocation` and the `#_sourceLocation` macro so that any failed expectations print messages about the test where they failed rather than a location inside the verification method.
**Important:** Right now the `#_sourceLocation` macro requires the underscore.
For example:
```swift
func verifyDivision(_ result: (quotient: Int, remainder: Int), expectedQuotient: Int, expectedRemainder: Int, sourceLocation: SourceLocation = #_sourceLocation) {
#expect(result.quotient == expectedQuotient, sourceLocation: sourceLocation)
#expect(result.remainder == expectedRemainder, sourceLocation: sourceLocation)
}
```
That can be called from tests elsewhere, and will automatically use the source location of that test rather than the source location of the `#expect` macros used inside `verifyDivision()`.
`#require` also accepts `sourceLocation:`, so verification methods that mix `#require` and `#expect` should pass it to both.

View File

@@ -0,0 +1,102 @@
---
name: swiftdata-pro
description: Writes, reviews, and improves SwiftData code using modern APIs and best practices. Use when reading, writing, or reviewing projects that use SwiftData.
license: MIT
metadata:
author: Paul Hudson
version: "1.0"
---
Write and review SwiftData code for correctness, modern API usage, and adherence to project conventions. Report only genuine problems - do not nitpick or invent issues.
Review process:
1. Check for core SwiftData issues using `references/core-rules.md`.
1. Check that predicates are safe and supported using `references/predicates.md`.
1. If the project uses CloudKit, check for CloudKit-specific constraints using `references/cloudkit.md`.
1. If the project targets iOS 18+, check for indexing opportunities using `references/indexing.md`.
1. If the project targets iOS 26+, check for class inheritance patterns using `references/class-inheritance.md`.
If doing partial work, load only the relevant reference files.
## Core Instructions
- Target Swift 6.2 or later, using modern Swift concurrency.
- The user strongly prefers to use SwiftData across the board. Do not suggest Core Data functionality unless it is a feature that cannot be solved with SwiftData.
- Do not introduce third-party frameworks without asking first.
- Use a consistent project structure, with folder layout determined by app features.
## Output Format
If the user asks for a review, organize findings by file. For each issue:
1. State the file and relevant line(s).
2. Name the rule being violated.
3. Show a brief before/after code fix.
Skip files with no issues. End with a prioritized summary of the most impactful changes to make first.
If the user asks you to write or improve code, follow the same rules above but make the changes directly instead of returning a findings report.
Example output:
### Destination.swift
**Line 8: Add an explicit delete rule for relationships.**
```swift
// Before
var sights: [Sight]
// After
@Relationship(deleteRule: .cascade, inverse: \Sight.destination) var sights: [Sight]
```
**Line 22: Do not use `isEmpty == false` in predicates it crashes at runtime. Use `!` instead.**
```swift
// Before
#Predicate<Destination> { $0.sights.isEmpty == false }
// After
#Predicate<Destination> { !$0.sights.isEmpty }
```
### DestinationListView.swift
**Line 5: `@Query` must only be used inside SwiftUI views.**
```swift
// Before
class DestinationStore {
@Query var destinations: [Destination]
}
// After
class DestinationStore {
var modelContext: ModelContext
func fetchDestinations() throws -> [Destination] {
try modelContext.fetch(FetchDescriptor<Destination>())
}
}
```
### Summary
1. **Data loss (high):** Missing delete rule on line 8 of Destination.swift means sights will be orphaned when a destination is deleted.
2. **Crash (high):** `isEmpty == false` on line 22 will crash at runtime use `!isEmpty` instead.
3. **Incorrect behavior (high):** `@Query` on line 5 of DestinationListView.swift only works inside SwiftUI views.
End of example.
## References
- `references/core-rules.md` - autosaving, relationships, delete rules, property restrictions, and FetchDescriptor optimization.
- `references/predicates.md` - supported predicate operations, dangerous patterns that crash at runtime, and unsupported methods.
- `references/cloudkit.md` - CloudKit-specific constraints including uniqueness, optionality, and eventual consistency.
- `references/indexing.md` - database indexing for iOS 18+, including single and compound property indexes.
- `references/class-inheritance.md` - model subclassing for iOS 26+, including @available requirements, schema setup, and predicate filtering.

View File

@@ -0,0 +1,10 @@
interface:
display_name: "SwiftData Pro"
short_description: "Reviews SwiftData code for modern best practices."
icon_small: "./assets/swiftdata-pro-icon.svg"
icon_large: "./assets/swiftdata-pro-icon.png"
brand_color: "#5A7585"
default_prompt: "Use $swiftdata-pro to review my project."
policy:
allow_implicit_invocation: true

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="227.72" height="227.72" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 227.72 227.72">
<defs>
<style>
.st0 {
fill: none;
stroke: url(#linear-gradient1);
stroke-miterlimit: 10;
stroke-width: 6px;
}
.st1 {
fill: url(#linear-gradient);
fill-rule: evenodd;
}
</style>
<linearGradient id="linear-gradient" x1="58.405" y1="-63.045" x2="178.785" y2="-183.425" gradientTransform="translate(0 -10) scale(1 -1)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#99abb6"/>
<stop offset="1" stop-color="#567182"/>
</linearGradient>
<linearGradient id="linear-gradient1" x1="33.349" y1="-51.349" x2="194.371" y2="-212.371" gradientTransform="translate(0 -18) scale(1 -1)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#00eeaf"/>
<stop offset="0" stop-color="#99abb6"/>
<stop offset="1" stop-color="#567182"/>
</linearGradient>
</defs>
<path class="st1" d="M165.43,131.08c3.87-8.17,16.03-46.62-38.6-85.38,7.61,7.03,40.19,39.2,26.05,75.5-34.2-25.27-87.91-66.75-87.91-66.75,0,0,63.69,60.57,83.58,79.15,19.9,18.59,26.97,27.71,26.47,43.59,0,.01,14.58-23.23-9.59-46.11ZM130.89,145.79c-39.35,18.56-78.42-20.82-78.42-20.82,0,0,21.12,26.7,45.19,35.06,33.72,11.71,54.61-9.64,54.61-9.64L52.42,59.03s56.43,62.62,78.47,86.76h0Z"/>
<circle class="st0" cx="113.86" cy="113.86" r="110.86"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,104 @@
# Class inheritance
When supporting iOS 26 and other coordinated releases (macOS 26, etc), SwiftData supports class inheritance for models.
**Important:** This is not a common feature; only add model subclassing if it actually has a benefit. Alternatives such as protocols are often simpler and better.
This works the same as regular class inheritance in Swift, however, child classes must be explicitly marked `@available` for a 26 release or later, e.g. iOS 26. This is required even if iOS 26 is set as the minimum deployment target.
For example:
```swift
@Model class Article {
var type: String
init(type: String) {
self.type = type
}
}
@available(iOS 26, *)
@Model class Tutorial: Article {
var difficulty: Int
init(difficulty: Int) {
self.difficulty = difficulty
super.init(type: "Tutorial")
}
}
@available(iOS 26, *)
@Model class News: Article {
var topic: String
init(topic: String) {
self.topic = topic
super.init(type: "News")
}
}
```
Notice how both the parent and child classes must use the `@Model` macro.
**Important:** When using a 26 release or later as minimum deployment target, we must still mark subclassed models with `@available`. However, we do *not* need to do the same with code using that model, because Xcode can match the deployment target and the model availability.
When providing the schemas as part of model container creation, make sure to list both the parent class and its child classes SwiftData is *not* able to infer the connection by itself.
If you create a relationship to a model that has subclasses, the relationship might contain the parent class or any of its subclasses.
For example, the `articles` array here might contain `Article`, `Tutorial`, or `News` instances:
```swift
@Model class Magazine {
@Relationship(deleteRule: .cascade) var articles: [Article]
init(articles: [Article]) {
self.articles = articles
}
}
```
If only one subclass is supported, it should be written specifically. If several subclasses but not all should be in the relationship, you might have no choice but to add another level of subclasses: BaseClass -> Subclass -> Subsubclass. However, this is not a good idea deep subclassing is generally frowned upon, and will increase complexity in migrations.
## Filtering with subclasses
One important benefit of model subclassing is that we can use `@Query` to look for specific subclasses, *or* to look for the base class, which will automatically return all child classes too.
For example, we could load only tutorials like this:
```swift
@Query private var tutorials: [Tutorial]
```
Or load *all* articles, including tutorials, like this:
```swift
@Query private var articles: [Article]
```
If you want to load specific child classes but not the parent class, use `is` with the `#Predicate` macro to perform filtering:
```swift
@Query(filter: #Predicate<Article> {
$0 is Tutorial || $0 is News
}) private var tutorialsAndNews: [Article]
```
**Important:** The type of the resulting array elements is `Article`, the parent class, so typecasting must be used to access child-class properties and methods.
It's possible to do typecasting inside predicates to filter based on child-class properties. For example, this looks for easier tutorials and general news to create a list of articles suitable for the front page:
```swift
@Query(filter: #Predicate<Article> { article in
if let tutorial = article as? Tutorial {
tutorial.difficulty < 3
} else if let news = article as? News {
news.topic == "General"
} else {
false
}
}) private var frontPageArticles: [Article]
```
When working with the resulting data, regular Swift typecasting using `as` works fine.

View File

@@ -0,0 +1,10 @@
# Using SwiftData with CloudKit
**These rules only apply if the project is configured to use SwiftData with CloudKit.**
- Never use `@Attribute(.unique)` or `#Unique`; they are *not* supported in CloudKit, and when used will cause local data to fail too.
- All model properties must always either have default values or be marked as optional.
- All relationships must be marked optional.
- Indexes and subclasses are supported in CloudKit, as long as the correct OS release is used.
Keep in mind that CloudKit is designed for *eventual consistency* any SwiftData code written with CloudKit support must be able to function if data has yet to synchronize.

View File

@@ -0,0 +1,20 @@
# Core rules
- When SwiftData first launched, it autosaved model contexts aggressively. Since then, autosaving happens less frequently and is now hard to predict, so many developers prefer to add explicit calls to `save()` when correctness is important.
- There is no need to check `modelContext.hasChanges` before saving; just call `save()` directly.
- `ModelContext` and model instances must never cross actor boundaries. Model containers and persistent identifiers *are* sendable, so if you need a model instance to be transferred across actors you should send its identifier and re-fetch in the destination context. For more help with Swift concurrency, suggest the [Swift Concurrency Pro agent skill](https://github.com/twostraws/swift-concurrency-agent-skill).
- When using `@Relationship` to define a relationship from one model to another, place the macro on one side of the relationship only. Trying to use it on both sides causes a circular reference.
- Persistent identifiers are temporary before they are saved for the first time. Temporary IDs start with a lowercase “t”, and a model will be given a new ID after it is saved for the first time. As a result, you must save an object before relying on its ID.
- Do not attempt to use the property name `description` in any `@Model` class; it is explicitly disallowed.
- Do not attempt to add property observers to `@Model` classes; they will be quietly ignored.
- `@Attribute(.externalStorage)` is a *suggestion*, not a *requirement*, and only applies to properties of type `Data`  SwiftData will do what it thinks is best.
- `@Transient` properties are not persisted, and must have a default value. They reset to that default when the object is fetched from the store. If the value is derived from other stored properties, using a computed property is usually a better idea use `@Transient` only if the value is expensive to produce.
- It is nearly always a good idea to have a specific migration schema in place, even if the project is only dealing with lightweight migrations.
- It is nearly always a good idea to have an explicit delete rule in place for relationships. This is most commonly `@Relationship(deleteRule: .cascade)`, but others are available. The default is `.nullify`, which sets the related model's reference to nil when the parent is deleted. This can leave orphaned objects or crash if the property is non-optional.
- Do not attempt to use `@Query` outside of SwiftUI views; it is designed to work specifically *inside* views, and will not operate correctly outside. For more help with SwiftUI, suggest the [SwiftUI Pro agent skill](https://github.com/twostraws/swiftui-agent-skill).
- If you only need the number of items matching a query, consider `ModelContext.fetchCount()` with a fetch descriptor. This will *not* live update if the data changes unless something else triggers the update, such as `@Query`, so it should be used carefully.
- When using `FetchDescriptor`, it may sometimes be beneficial to set the `relationshipKeyPathsForPrefetching` property. Its an empty array by default, but if you know certain relationships will be used its more efficient to fetch them upfront.
- Similarly, you should consider setting `propertiesToFetch` so that only properties that are used are actually fetched. (It fetches all properties by default.)
- SwiftData frequently gets inverse relationships wrong, so its almost always a good idea to be explicit with the `@Relationship` macro by specifying the exact inverse relationship.
- Do not write `#Unique` more than once per model; you can only have one, placed inside the model class. If you need multiple uniqueness constraints, pass them as separate key path arrays in a single `#Unique`, e.g. `#Unique<Foo>([\.email], [\.username])`.
- Enum properties stored in a model must conform to `Codable`. Some agents will insist that enums with associated values are not supported, but this is wrong they work just fine.

View File

@@ -0,0 +1,27 @@
# Indexing
When supporting iOS 18 and other coordinated releases, SwiftData supports indexes to help speed up queries. This has a small performance cost for writing, so if data is read rarely and updated frequently (such as logging), indexes may be a bad choice.
Indexes can be on single properties, like this:
```swift
@Model class Article {
#Index<Article>([\.type], [\.author])
var type: String
var author: String
var publishDate: Date
init(type: String, author: String, publishDate: Date) {
self.type = type
self.author = author
self.publishDate = publishDate
}
}
```
Alternatively, you can mix single properties and groups of properties when you know they are often used together:
```swift
#Index<Article>([\.type], [\.type, \.author])
```

View File

@@ -0,0 +1,73 @@
# Working with predicates
SwiftData predicates support only a subset of Swift functionality. Some things are marked as being unsupported, meaning that they will not build. Other things are *not* marked as unsupported and yet are still not supported, meaning that they will build but crash at runtime.
This guide contains specific guidance on what to use and when.
## String matching
When writing a query predicate to perform string matching, always use `localizedStandardContains()` rather than trying to use `lowercased().contains()` or similar.
For example, this is correct:
```swift
@Query(filter: #Predicate<Movie> {
$0.name.localizedStandardContains("titanic")
}) private var movies: [Movie]
```
## hasPrefix()
`hasPrefix()` and `hasSuffix()` are not supported in SwiftData predicates. If you want to use `hasPrefix()`, you should use `starts(with:)` instead, like this:
```swift
@Query(filter: #Predicate<Website> {
$0.type.starts(with: "https://apple.com")
}) private var appleLinks: [Website]
```
## Unsupported predicates
Many common methods have no equivalent in SwiftData, and will not compile. For example, all these common operations are not supported:
- `String.hasSuffix()`
- `String.lowercased()`
- `Sequence.map()`
- `Sequence.reduce()`
- `Sequence.count(where:)`
- `Collection.first`
Custom operators are also not allowed.
## Dangerous predicates
Some SwiftData predicates will compile cleanly then fail or even crash at runtime.
For example, this is a valid predicate designed to show only movies that have a non-empty cast list:
```swift
@Query(filter: #Predicate<Movie> { !$0.cast.isEmpty }, sort: \Movie.name) private var movies: [Movie]
```
However, *this* query looks like it does the same thing, but will crash at runtime:
```swift
@Query(filter: #Predicate<Movie> { $0.cast.isEmpty == false }, sort: \Movie.name) private var movies: [Movie]
```
Never attempt to create query predicates that use computed properties, `@Transient` properties, or use custom `Codable` struct data. They might compile cleanly, but they will crash at runtime.
All predicates must rely on data that is actually stored in the database as `@Model` classes.
Never attempt to use regular expressions in predicates. They will compile cleanly then fail at runtime. So, this is *not* allowed:
```swift
@Query(filter: #Predicate<Movie> {
$0.name.contains(/Titanic/)
}, sort: \Movie.name)
private var movies: [Movie]
```

View File

@@ -0,0 +1,10 @@
{
"name": "swiftui-pro",
"version": "1.0.0",
"description": "Reviews SwiftUI code for best practices on modern APIs, maintainability, and performance.",
"author": { "name": "Paul Hudson" },
"homepage": "https://github.com/twostraws/SwiftUI-Agent-Skill",
"repository": "https://github.com/twostraws/SwiftUI-Agent-Skill",
"license": "MIT",
"skills": "./skills/"
}

View File

@@ -0,0 +1,108 @@
---
name: swiftui-pro
description: Comprehensively reviews SwiftUI code for best practices on modern APIs, maintainability, and performance. Use when reading, writing, or reviewing SwiftUI projects.
license: MIT
metadata:
author: Paul Hudson
version: "1.1"
---
Review Swift and SwiftUI code for correctness, modern API usage, and adherence to project conventions. Report only genuine problems - do not nitpick or invent issues.
Review process:
1. Check for deprecated API using `references/api.md`.
1. Check that views, modifiers, and animations have been written optimally using `references/views.md`.
1. Validate that data flow is configured correctly using `references/data.md`.
1. Ensure navigation is updated and performant using `references/navigation.md`.
1. Ensure the code uses designs that are accessible and compliant with Apples Human Interface Guidelines using `references/design.md`.
1. Validate accessibility compliance including Dynamic Type, VoiceOver, and Reduce Motion using `references/accessibility.md`.
1. Ensure the code is able to run efficiently using `references/performance.md`.
1. Quick validation of Swift code using `references/swift.md`.
1. Final code hygiene check using `references/hygiene.md`.
If doing a partial review, load only the relevant reference files.
## Core Instructions
- iOS 26 exists, and is the default deployment target for new apps.
- Target Swift 6.2 or later, using modern Swift concurrency.
- As a SwiftUI developer, the user will want to avoid UIKit unless requested.
- Do not introduce third-party frameworks without asking first.
- Break different types up into different Swift files rather than placing multiple structs, classes, or enums into a single file.
- Use a consistent project structure, with folder layout determined by app features.
## Output Format
Organize findings by file. For each issue:
1. State the file and relevant line(s).
2. Name the rule being violated (e.g., "Use `foregroundStyle()` instead of `foregroundColor()`").
3. Show a brief before/after code fix.
Skip files with no issues. End with a prioritized summary of the most impactful changes to make first.
Example output:
### ContentView.swift
**Line 12: Use `foregroundStyle()` instead of `foregroundColor()`.**
```swift
// Before
Text("Hello").foregroundColor(.red)
// After
Text("Hello").foregroundStyle(.red)
```
**Line 24: Icon-only button is bad for VoiceOver - add a text label.**
```swift
// Before
Button(action: addUser) {
Image(systemName: "plus")
}
// After
Button("Add User", systemImage: "plus", action: addUser)
```
**Line 31: Avoid `Binding(get:set:)` in view body - use `@State` with `onChange()` instead.**
```swift
// Before
TextField("Username", text: Binding(
get: { model.username },
set: { model.username = $0; model.save() }
))
// After
TextField("Username", text: $model.username)
.onChange(of: model.username) {
model.save()
}
```
### Summary
1. **Accessibility (high):** The add button on line 24 is invisible to VoiceOver.
2. **Deprecated API (medium):** `foregroundColor()` on line 12 should be `foregroundStyle()`.
3. **Data flow (medium):** The manual binding on line 31 is fragile and harder to maintain.
End of example.
## References
- `references/accessibility.md` - Dynamic Type, VoiceOver, Reduce Motion, and other accessibility requirements.
- `references/api.md` - updating code for modern API, and the deprecated code it replaces.
- `references/design.md` - guidance for building accessible apps that meet Apples Human Interface Guidelines.
- `references/hygiene.md` - making code compile cleanly and be maintainable in the long term.
- `references/navigation.md` - navigation using `NavigationStack`/`NavigationSplitView`, plus alerts, confirmation dialogs, and sheets.
- `references/performance.md` - optimizing SwiftUI code for maximum performance.
- `references/data.md` - data flow, shared state, and property wrappers.
- `references/swift.md` - tips on writing modern Swift code, including using Swift Concurrency effectively.
- `references/views.md` - view structure, composition, and animation.

View File

@@ -0,0 +1,10 @@
interface:
display_name: "SwiftUI Pro"
short_description: "Reviews SwiftUI code for modern best practices."
icon_small: "./assets/swiftui-pro-icon.svg"
icon_large: "./assets/swiftui-pro-icon.png"
brand_color: "#006AFD"
default_prompt: "Use $swiftui-pro to review my project."
policy:
allow_implicit_invocation: true

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="227.72" height="227.72" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 227.72 227.72">
<defs>
<style>
.st0 {
fill: none;
stroke: url(#linear-gradient1);
stroke-miterlimit: 10;
stroke-width: 6px;
}
.st1 {
fill: url(#linear-gradient);
fill-rule: evenodd;
}
</style>
<linearGradient id="linear-gradient" x1="58.405" y1="206.955" x2="178.785" y2="86.575" gradientTransform="translate(0 260) scale(1 -1)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#00d9ff"/>
<stop offset="1" stop-color="#006efd"/>
</linearGradient>
<linearGradient id="linear-gradient1" x1="33.349" y1="218.651" x2="194.371" y2="57.629" gradientTransform="translate(0 252) scale(1 -1)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#00eeaf"/>
<stop offset="0" stop-color="#00d8ff"/>
<stop offset="1" stop-color="#006ffd"/>
</linearGradient>
</defs>
<path class="st1" d="M165.43,131.08c3.87-8.17,16.03-46.62-38.6-85.38,7.61,7.03,40.19,39.2,26.05,75.5-34.2-25.27-87.91-66.75-87.91-66.75,0,0,63.69,60.57,83.58,79.15,19.9,18.59,26.97,27.71,26.47,43.59,0,.01,14.58-23.23-9.59-46.11ZM130.89,145.79c-39.35,18.56-78.42-20.82-78.42-20.82,0,0,21.12,26.7,45.19,35.06,33.72,11.71,54.61-9.64,54.61-9.64L52.42,59.03s56.43,62.62,78.47,86.76Z"/>
<circle class="st0" cx="113.86" cy="113.86" r="110.86"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,13 @@
# Accessibility
- Respect the users accessibility settings for fonts, colors, animations, and more.
- Do not force specific font sizes. Prefer Dynamic Type (`.font(.body)`, `.font(.headline)`, etc.).
- If you *need* a custom font size, use `@ScaledMetric` when targeting iOS 18 and earlier. When targeting iOS 26 or later, `.font(.body.scaled(by:))` is also available to get font size adjustment.
- Flag instances where images have unclear or unhelpful VoiceOver readings, e.g. `Image(.newBanner2026)`. If they are decorative, suggest using `Image(decorative:)` or `accessibilityHidden()`, otherwise attach an `accessibilityLabel()`.
- If the user has “Reduce Motion” enabled, replace large, motion-based animations with opacity instead.
- If buttons have complex or frequently changing labels, recommend using `accessibilityInputLabels()` to provide better Voice Control commands. For example, if a button had a live-updating share price for Apple such as “AAPL $271.68”, adding an input label for “Apple” would be a big improvement.
- Buttons with image labels must always include text, even if the text is invisible: `Button("Label", systemImage: "plus", action: myAction)`. Flag icon-only buttons that lack a text label as being bad for VoiceOver. Usually SwiftUI will make labels use the correct label style based on their context e.g. buttons in iOS toolbars will automatically be icon-only by default but if there's a specific reason for a button to remain visually icon-only, apply `.labelStyle(.iconOnly)` to preserve the visual while keeping the text available for VoiceOver.
- If color is an important differentiator in the user interface, make sure to respect the environments `.accessibilityDifferentiateWithoutColor` setting by showing some kind of variation beyond just color icons, patterns, strokes, etc.
- The same is true of `Menu`: using `Menu("Options", systemImage: "ellipsis.circle") { }` is much better than just using an image. In the rare case where the menu trigger should really display only the icon, `.labelStyle(.iconOnly)` can be used.
- Never use `onTapGesture()` unless you specifically need tap location or tap count. All other tappable elements should be a `Button`.
- If `onTapGesture()` must be used, make sure to add `.accessibilityAddTraits(.isButton)` or similar so it can be read by VoiceOver correctly.

View File

@@ -0,0 +1,39 @@
# Using modern SwiftUI API
- Always use `foregroundStyle()` instead of `foregroundColor()`.
- Always use `clipShape(.rect(cornerRadius:))` instead of `cornerRadius()`.
- Always use the `Tab` API instead of `tabItem()`.
- Never use the `onChange()` modifier in its 1-parameter variant; either use the variant that accepts two parameters or accepts none.
- Do not use `GeometryReader` if a newer alternative works: `containerRelativeFrame()`, `visualEffect()`, or the `Layout` protocol. Flag `GeometryReader` usage and suggest the modern alternative.
- When designing haptic effects, prefer using `sensoryFeedback()` over older UIKit APIs such as `UIImpactFeedbackGenerator`.
- Use the `@Entry` macro to define custom `EnvironmentValues`, `FocusValues`, `Transaction`, and `ContainerValues` keys. This replaces the legacy pattern of manually creating a type conforming to (for example) `EnvironmentKey` with a `defaultValue`, then extending `EnvironmentValues` with a computed property.
- Strongly prefer `overlay(alignment:content:)` over the deprecated `overlay(_:alignment:)`. For example, use `.overlay { Text("Hello, world!") }` rather than `.overlay(Text("Hello, world!"))`.
- Never use `.navigationBarLeading` and `.navigationBarTrailing` for toolbar item placement; they are deprecated. The correct, modern placements are `.topBarLeading` and `.topBarTrailing`.
- Prefer to rely on automatic grammar agreement when dealing with English, French, German, Portuguese, Spanish, and Italian. For example, use `Text("^[\(people) person](inflect: true)")` to show a number of people.
- You can fill and stroke a shape with two chained modifiers; you do *not* need an overlay for the stroke. The overlay was required previously, but this is fixed in iOS 17 and later.
- When referencing images from an asset catalog, prefer the generated symbol asset API when the project is configured to use them: `Image(.avatar)` rather than `Image("avatar")`.
- When targeting iOS 26 and later, SwiftUI has a native `WebView` view type that replaces almost all uses of hand-wrapped `WKWebView` inside `UIViewRepresentable`. To use it, make sure to include `import WebKit`.
- `ForEach` over an `enumerated()` sequence should not convert to an array first. Use `ForEach(items.enumerated(), id: \.element.id)` directly.
- When hiding scroll indicators, use `.scrollIndicators(.hidden)` rather than `showsIndicators: false` in the initializer.
- Never use `Text` concatenation with `+`.
For example, the usage of `+` here is bad and deprecated:
```swift
Text("Hello").foregroundStyle(.red)
+
Text("World").foregroundStyle(.blue)
```
Instead, use text interpolation like this:
```swift
let red = Text("Hello").foregroundStyle(.red)
let blue = Text("World").foregroundStyle(.blue)
Text("\(red)\(blue)")
```
## Using ObservableObject
If using `ObservableObject` is absolutely required for example if you are trying to create a debouncer using a Combine publisher you should always make sure `import Combine` is added. This was previously provided through SwiftUI, but that is no longer the case.

View File

@@ -0,0 +1,43 @@
# Data flow, shared state, and property wrappers
It is important that SwiftUI body code and logic code be kept separate in order to make code easier to read, write, and maintain. That usually means placing code into methods rather than inline in the `body` property, but often also means carving functionality out into separate `@Observable` classes.
These rules help ensure code is efficient and works well in the long term.
## Shared state
- `@Observable` classes must be marked `@MainActor` unless the project has Main Actor default actor isolation. Flag any `@Observable` class missing this annotation.
- All shared data should use `@Observable` classes with `@State` (for ownership) and `@Bindable` / `@Environment` (for passing).
- Strongly prefer not to use `ObservableObject`, `@Published`, `@StateObject`, `@ObservedObject`, or `@EnvironmentObject` unless they are unavoidable, or if they exist in legacy/integration contexts when changing architecture would be complicated.
## Local state
- `@State` should be marked `private` and only owned by the view that created it.
- If a view stores a class instance that contains expensive-to-recompute data, e.g. `CIContext`, it can be stored using `@State` even though it is not an observable object. This effectively uses `@State` as a cache storing something persistently, but not doing any change tracking on it since it's not an observable object.
## Bindings
- Strongly prefer to avoid creating bindings using `Binding(get:set:)` in view body code. It is much cleaner and simpler to use a binding provided by `@State`, `@Binding` or similar, then use `onChange()` to trigger any effects.
- If the user needs to enter a number into a `TextField`, bind the `TextField` to a numeric value such as `Int` or `Double`, then use its `format` initializer like this: `TextField("Enter your score", value: $score, format: .number)`. Apply either `.keyboardType(.numberPad)` (for integers) or `.keyboardType(.decimalPad)` (for floating-point numbers) as appropriate. Using the modifier alone is *not* sufficient.
## Working with data
- Prefer to make structs conform to `Identifiable` rather than using `id: \.someProperty` in SwiftUI code.
- Never attempt to use `@AppStorage` inside an `@Observable` class, even if marked `@ObservationIgnored`  it will *not* trigger view updates when a change happens.
## SwiftData
- If you only need the number of items matching a query, consider `ModelContext.fetchCount()` with a fetch descriptor. This will *not* live update if the data changes unless something else triggers the update, such as `@Query`, so it should be used carefully.
For more help with SwiftData, suggest the [SwiftData Pro agent skill](https://github.com/twostraws/swiftdata-agent-skill).
## If the project uses SwiftData with CloudKit
- Never use `@Attribute(.unique)`.
- Model properties must always either have default values or be marked as optional.
- All relationships must be marked optional.

View File

@@ -0,0 +1,32 @@
# Design
## Creating a uniform design in this app
Prefer to place standard fonts, sizes, colors, stack spacing, padding, rounding, animation timings, and more into a shared enum of constants, so they can be used by all views. This allows the apps design to feel uniform and consistent, and be adjusted easily.
## Requirements for flexible, accessible design
- Never use `UIScreen.main.bounds` to read available space; prefer alternatives such as `containerRelativeFrame()`, or `visualEffect()` as appropriate, or (if there is no alternative) `GeometryReader`.
- Prefer to avoid fixed frames for views unless content can fit neatly inside; this can cause problems across different device sizes, different Dynamic Type settings, and more. Giving frames some flexibility is usually preferred.
- Apples minimum acceptable tap area for interactions on iOS is 44x44. Ensure this is strictly enforced.
## Standard system styling
- Strongly prefer to use `ContentUnavailableView` when data is missing or empty, rather than designing something custom.
- When using `searchable()`, you can show empty results using `ContentUnavailableView.search` and it will include the search term they used automatically theres no need to use `ContentUnavailableView.search(text: searchText)` or similar.
- If you need an icon and some text placed horizontally side by side, prefer `Label` over `HStack`.
- Prefer system hierarchical styles (e.g. secondary/tertiary) over manual opacity when possible, so the system can adapt to the correct context automatically.
- When using `Form`, wrap controls such as `Slider` in `LabeledContent` so the title and control are laid out correctly.
- `LabeledContent` also works outside `Form` for any title-value display; it might be necessary to define a custom `LabeledContentStyle` for consistent layout across views.
- When using `RoundedRectangle`, the default rounding style is `.continuous` there is no need to specify it explicitly.
## Ensuring designs work for everyone
- Use `bold()` instead of `fontWeight(.bold)`, because using `bold()` allows the system to choose the correct weight for the current context.
- Only use `fontWeight()` for weights other than bold when there's an important reason - scattering around `fontWeight(.medium)` or `fontWeight(.semibold)` is counterproductive.
- Avoid hard-coded values for padding and stack spacing unless specifically requested.
- Avoid UIKit colors (`UIColor`) in SwiftUI code; use SwiftUI `Color` or asset catalog colors.
- The font size `.caption2` is extremely small, and is generally best avoided. Even the font size `.caption` is on the small side, and should be used carefully.

View File

@@ -0,0 +1,9 @@
# Hygiene
- If the project requires secrets such as API keys, never include them in the repository.
- Code comments and documentation comments should be present where the logic isn't self-evident.
- Unit tests should exist for core application logic. UI tests only where unit tests are not possible.
- `@AppStorage` must never be used to store usernames, passwords, or other sensitive data. Use the keychain for that.
- If SwiftLint is configured, it should return no warnings or errors.
- If the project uses Localizable.xcstrings, prefer to add user-facing strings using symbol keys (e.g. “helloWorld”) in the string catalog with `extractionState` set to "manual", accessing them via generated symbols such as `Text(.helloWorld)`. Offer to translate new keys into all languages supported by the project.
- If the Xcode MCP is configured, prefer its tools over generic alternatives. For example, `RenderPreview` is able to capture images of rendered SwiftUI previews for examination, and `DocumentationSearch` can search Apples documentation for latest usage instructions.

View File

@@ -0,0 +1,14 @@
# Navigation and presentation
- Use `NavigationStack` or `NavigationSplitView` as appropriate; flag all use of the deprecated `NavigationView`.
- Strongly prefer to use `navigationDestination(for:)` to specify destinations; flag all use of the old `NavigationLink(destination:)` pattern where it should be replaced.
- Never mix `navigationDestination(for:)` and `NavigationLink(destination:)` in the same navigation hierarchy; it causes significant problems.
- `navigationDestination(for:)` must be registered once per data type; flag duplicates.
## Alerts, confirmation dialogs, and sheets
- Always attach `confirmationDialog()` to the user interface that triggers the dialog. This allows Liquid Glass animations to move from the correct source.
- If an alert has only a single “OK” button that does nothing but dismiss the alert, it can be omitted entirely: `.alert("Dismiss Me", isPresented: $isShowingAlert) { }`.
- If a sheet is designed to present an optional piece of data, prefer `sheet(item:)` over `sheet(isPresented:)` so the optional is safely unwrapped.
- When using `sheet(item:)` with a view that accepts the item as its only initializer parameter, prefer `sheet(item: $someItem, content: SomeView.init)` over `sheet(item: $someItem) { someItem in SomeView(item: someItem) }`.

View File

@@ -0,0 +1,46 @@
# Performance
- When toggling modifier values, prefer ternary expressions over if/else view branching to avoid `_ConditionalContent`, preserve structural identity, and avoid repeatedly recreating underlying platform views.
- Avoid `AnyView` unless absolutely required. Use `@ViewBuilder`, `Group`, or generics instead.
- If a `ScrollView` has an opaque, static, and solid background, prefer to use `scrollContentBackground(.visible)` to improve scroll-edge rendering efficiency.
- It is more efficient to break views up by making dedicated SwiftUI views rather than place them into computed properties or methods. Using `@ViewBuilder` on a property or method does not solve this; breaking views up is strongly preferred.
- Always ensure view initializers are kept as small and simple as possible, avoiding any non-trivial work. Flag any work that can be moved into a `task()` modifier to be run when the view is shown.
- Similarly, assume each views `body` property is called frequently  if logic such as sorting or filtering can be moved out of there easily, it should be.
- Avoid creating properties to store formatters such as `DateFormatter` unless they are required. A more natural approach is to use `Text` with a format, like this: `Text(Date.now, format: .dateTime.day().month().year())` or `Text(100, format: .currency(code: "USD"))`.
- Avoid expensive inline transforms in `List`/`ForEach` initializers (e.g. `items.filter { ... }`) when they are repeated often.
- Prefer deriving transformed data from the source-of-truth using `let`, or caching in `@State`. However, do not cache derived collections in `@State` unless you also own explicit invalidation logic to avoid stale UI.
- For large data sets in `ScrollView`, use `LazyVStack`/`LazyHStack`; flag eager stacks with many children.
- Prefer using `task()` over `onAppear()` when doing async work, because it will be cancelled automatically when the view disappears.
- Avoid storing escaping `@ViewBuilder` closures on views when possible; store built view results instead.
Example:
```swift
// Anti-pattern: stores an escaping closure on the view.
struct CardView<Content: View>: View {
let content: () -> Content
var body: some View {
VStack(alignment: .leading) {
content()
}
.padding()
.background(.ultraThinMaterial)
.clipShape(.rect(cornerRadius: 8))
}
}
// Preferred: store the built view value; the synthesized init handles calling the builder.
struct CardView<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading) {
content
}
.padding()
.background(.ultraThinMaterial)
.clipShape(.rect(cornerRadius: 8))
}
}
```

View File

@@ -0,0 +1,56 @@
# Swift
- Prefer Swift-native string methods over Foundation equivalents: use `replacing("a", with: "b")` not `replacingOccurrences(of: "a", with: "b")`.
- Prefer modern Foundation API: `URL.documentsDirectory` instead of `FileManager` directory lookups, `appending(path:)` to append strings to a URL.
- Never use C-style number formatting like `String(format: "%.2f", value)`. Use `Text(value, format: .number.precision(.fractionLength(2)))` or similar `FormatStyle` APIs.
- Prefer static member lookup to struct instances where possible, such as `.circle` rather than `Circle()`, and `.borderedProminent` rather than `BorderedProminentButtonStyle()`.
- Avoid force unwraps (`!`) and force `try` unless the failure is truly unrecoverable, and even then prefer using `fatalError()` with a clear description. If possible, use `if let`, `guard let`, nil-coalescing, or `try?`/`do-catch`.
- Filtering text based on user-input must be done using `localizedStandardContains()` as opposed to `contains()` or `localizedCaseInsensitiveContains()`.
- Strongly prefer `Double` over `CGFloat`, except when using optionals or `inout`; Swift is able to bridge the two freely except in those two cases.
- If you want to count array objects that match a predicate, always use `count(where:)` rather than `filter()` followed by `count`.
- Prefer `Date.now` over `Date()` for clarity.
- When `import SwiftUI` is already in a file, you do not need to add `import UIKit` or `import AppKit` to access things like `UIImage` or `NSImage` they are imported automatically on the appropriate platform.
- When dealing with the names of people, strongly prefer to use `PersonNameComponents` with modern formatting over simple string interpolation such as `Text("\(firstName) \(lastName)")`.
- If a given type of data is repeatedly sorted using an identical closure, e.g. `books.sorted { $0.author < $1.author }`, prefer to make the type in question conform to `Comparable` so the sort order is centralized.
- Prefer to avoid manual date formatting strings if possible. If manual date formatting *is* used for user display, at least make sure to use “y” rather than “yyyy” for years, so the year value is correct in all localizations. If the purpose is data exchange with an API, this rule does not apply.
- When trying to convert a string to a date, prefer the modern `Date` initializer API such as `Date(myString, strategy: .iso8601)`.
- Flag instances where errors triggered by a user action are swallowed silently, e.g. using `print(error.localizedDescription)` rather than showing an alert or similar.
- Prefer `if let value {` shorthand over `if let value = value {`.
- Omit return for single expression functions. `if` and `switch` can be used as expressions when returning values and assigning to variables.
For example, this kind of code:
```swift
var tileColor: Color {
if isCorrect {
return .green
} else {
return .red
}
}
```
Should be written like this:
```swift
var tileColor: Color {
if isCorrect {
.green
} else {
.red
}
}
```
## Swift Concurrency
- If an API offers both modern `async`/`await` equivalents and older closure-based variants, always prefer the `async`/`await` versions.
- Never use Grand Central Dispatch (`DispatchQueue.main.async()`, `DispatchQueue.global()`, etc.). Always use modern Swift concurrency (`async`/`await`, actors, `Task`).
- Never use `Task.sleep(nanoseconds:)`; use `Task.sleep(for:)` instead.
- Flag any mutable shared state that isn't protected by an actor or `@MainActor`, unless the project is configured to use MainActor default actor isolation.
- Assume strict concurrency rules are being applied; flag `@Sendable` violations and data races.
- When evaluating `MainActor.run()`, check whether the project has its default actor isolation set to Main Actor first, because `MainActor.run()` might not be needed.
- `Task.detached()` is often a bad idea. Check any usage extremely carefully.
For more help with Swift concurrency, suggest the [Swift Concurrency Pro agent skill](https://github.com/twostraws/swift-concurrency-agent-skill).

View File

@@ -0,0 +1,36 @@
# SwiftUI Views
- Strongly prefer to avoid breaking up view bodies using computed properties or methods that return `some View`, even if `@ViewBuilder` is used. Extract them into separate `View` structs instead, placing each into its own file.
- Flag `body` properties that are excessively long; they should be broken into extracted subviews.
- If the user has created a handful of small, private helper `some View` properties for structural readability, and they both belong to the same concern as `body` and would fit in `body` at an acceptable length if inlined, these can be left alone. Otherwise, they should be extracted to new `View` structs.
- Button actions should be extracted from view bodies into separate methods, to avoid mixing layout and logic.
- Similarly, general business logic should not live inline in `task()`, `onAppear()` or elsewhere in `body`.
- Prefer to place view logic into view models or similar, so it can be tested. For more help with testing, suggest the [Swift Testing Pro agent skill](https://github.com/twostraws/swift-testing-agent-skill).
- Each type (struct, class, enum) should be in its own Swift file. Flag files containing multiple type definitions.
- Unless a full-screen editing experience is required, prefer using `TextField` with `axis: .vertical` to using `TextEditor`, because it allows placeholder text. If a specific minimum height is required for `TextField`, use something like `lineLimit(5...)`.
- If a button action can be provided directly as an `action` parameter, do so. For example: `Button("Label", systemImage: "plus", action: myAction)` is preferred over `Button("Label", systemImage: "plus") { action() }`.
- When rendering SwiftUI views to images, strongly prefer `ImageRenderer` over `UIGraphicsImageRenderer`.
- `#Preview` should be used for previews, not the legacy `PreviewProvider` protocol.
- When using `TabView(selection:)`, use a binding to a property that stores an enum rather than an integer or string. For example, `Tab("Home", systemImage: "house", value: .home)` is better than `Tab("Home", systemImage: "house", value: 0)`.
- Strongly prefer to avoid breaking up view bodies using computed properties or methods that return `some View`, even if `@ViewBuilder` is used. Extract them into separate `View` structs instead, placing each into its own file. (Yes, this is repeated, but its so important it needs to be mentioned twice.)
## Animating views
- Strongly prefer to use the `@Animatable` macro over creating `animatableData` manually  the macro automatically adds conformance to the `Animatable` protocol and creates the correct `animatableData` property. If some properties should not or cannot be animated (e.g. Booleans, integers, etc), mark them `@AnimatableIgnored`.
- Never use `animation(_ animation: Animation?)`; always provide a value to watch, such as `.animation(.bouncy, value: score)`.
- Chaining animations must be done using a `completion` closure passed to `withAnimation()`, rather than trying to execute multiple `withAnimation()` calls using delays.
For example:
```swift
Button("Animate Me") {
withAnimation {
scale = 2
} completion: {
withAnimation {
scale = 1
}
}
}
```

View File

@@ -0,0 +1,109 @@
---
name: swiftui-pro
description: Comprehensively reviews SwiftUI code for best practices on modern APIs, maintainability, and performance. Use when reading, writing, or reviewing SwiftUI projects.
license: MIT
argument-hint: "[focus area]"
metadata:
author: Paul Hudson
version: "1.0"
---
Review Swift and SwiftUI code for correctness, modern API usage, and adherence to project conventions. Report only genuine problems - do not nitpick or invent issues.
Review process:
1. Check for deprecated API using `${CLAUDE_SKILL_DIR}/references/api.md`.
1. Check that views, modifiers, and animations have been written optimally using `${CLAUDE_SKILL_DIR}/references/views.md`.
1. Validate that data flow is configured correctly using `${CLAUDE_SKILL_DIR}/references/data.md`.
1. Ensure navigation is updated and performant using `${CLAUDE_SKILL_DIR}/references/navigation.md`.
1. Ensure the code uses designs that are accessible and compliant with Apple's Human Interface Guidelines using `${CLAUDE_SKILL_DIR}/references/design.md`.
1. Validate accessibility compliance including Dynamic Type, VoiceOver, and Reduce Motion using `${CLAUDE_SKILL_DIR}/references/accessibility.md`.
1. Ensure the code is able to run efficiently using `${CLAUDE_SKILL_DIR}/references/performance.md`.
1. Quick validation of Swift code using `${CLAUDE_SKILL_DIR}/references/swift.md`.
1. Final code hygiene check using `${CLAUDE_SKILL_DIR}/references/hygiene.md`.
If doing a partial review, load only the relevant reference files.
## Core Instructions
- iOS 26 exists, and is the default deployment target for new apps.
- Target Swift 6.2 or later, using modern Swift concurrency.
- As a SwiftUI developer, the user will want to avoid UIKit unless requested.
- Do not introduce third-party frameworks without asking first.
- Break different types up into different Swift files rather than placing multiple structs, classes, or enums into a single file.
- Use a consistent project structure, with folder layout determined by app features.
## Output Format
Organize findings by file. For each issue:
1. State the file and relevant line(s).
2. Name the rule being violated (e.g., "Use `foregroundStyle()` instead of `foregroundColor()`").
3. Show a brief before/after code fix.
Skip files with no issues. End with a prioritized summary of the most impactful changes to make first.
Example output:
### ContentView.swift
**Line 12: Use `foregroundStyle()` instead of `foregroundColor()`.**
```swift
// Before
Text("Hello").foregroundColor(.red)
// After
Text("Hello").foregroundStyle(.red)
```
**Line 24: Icon-only button is bad for VoiceOver - add a text label.**
```swift
// Before
Button(action: addUser) {
Image(systemName: "plus")
}
// After
Button("Add User", systemImage: "plus", action: addUser)
```
**Line 31: Avoid `Binding(get:set:)` in view body - use `@State` with `onChange()` instead.**
```swift
// Before
TextField("Username", text: Binding(
get: { model.username },
set: { model.username = $0; model.save() }
))
// After
TextField("Username", text: $model.username)
.onChange(of: model.username) {
model.save()
}
```
### Summary
1. **Accessibility (high):** The add button on line 24 is invisible to VoiceOver.
2. **Deprecated API (medium):** `foregroundColor()` on line 12 should be `foregroundStyle()`.
3. **Data flow (medium):** The manual binding on line 31 is fragile and harder to maintain.
End of example.
## References
- `${CLAUDE_SKILL_DIR}/references/accessibility.md` - Dynamic Type, VoiceOver, Reduce Motion, and other accessibility requirements.
- `${CLAUDE_SKILL_DIR}/references/api.md` - updating code for modern API, and the deprecated code it replaces.
- `${CLAUDE_SKILL_DIR}/references/design.md` - guidance for building accessible apps that meet Apple's Human Interface Guidelines.
- `${CLAUDE_SKILL_DIR}/references/hygiene.md` - making code compile cleanly and be maintainable in the long term.
- `${CLAUDE_SKILL_DIR}/references/navigation.md` - navigation using `NavigationStack`/`NavigationSplitView`, plus alerts, confirmation dialogs, and sheets.
- `${CLAUDE_SKILL_DIR}/references/performance.md` - optimizing SwiftUI code for maximum performance.
- `${CLAUDE_SKILL_DIR}/references/data.md` - data flow, shared state, and property wrappers.
- `${CLAUDE_SKILL_DIR}/references/swift.md` - tips on writing modern Swift code, including using Swift Concurrency effectively.
- `${CLAUDE_SKILL_DIR}/references/views.md` - view structure, composition, and animation.

View File

@@ -0,0 +1,13 @@
# Accessibility
- Respect the users accessibility settings for fonts, colors, animations, and more.
- Do not force specific font sizes. Prefer Dynamic Type (`.font(.body)`, `.font(.headline)`, etc.).
- If you *need* a custom font size, use `@ScaledMetric` when targeting iOS 18 and earlier. When targeting iOS 26 or later, `.font(.body.scaled(by:))` is also available to get font size adjustment.
- Flag instances where images have unclear or unhelpful VoiceOver readings, e.g. `Image(.newBanner2026)`. If they are decorative, suggest using `Image(decorative:)` or `accessibilityHidden()`, otherwise attach an `accessibilityLabel()`.
- If the user has “Reduce Motion” enabled, replace large, motion-based animations with opacity instead.
- If buttons have complex or frequently changing labels, recommend using `accessibilityInputLabels()` to provide better Voice Control commands. For example, if a button had a live-updating share price for Apple such as “AAPL $271.68”, adding an input label for “Apple” would be a big improvement.
- Buttons with image labels must always include text, even if the text is invisible: `Button("Label", systemImage: "plus", action: myAction)`. Flag icon-only buttons that lack a text label as being bad for VoiceOver. Usually SwiftUI will make labels use the correct label style based on their context e.g. buttons in iOS toolbars will automatically be icon-only by default but if there's a specific reason for a button to remain visually icon-only, apply `.labelStyle(.iconOnly)` to preserve the visual while keeping the text available for VoiceOver.
- If color is an important differentiator in the user interface, make sure to respect the environments `.accessibilityDifferentiateWithoutColor` setting by showing some kind of variation beyond just color icons, patterns, strokes, etc.
- The same is true of `Menu`: using `Menu("Options", systemImage: "ellipsis.circle") { }` is much better than just using an image. In the rare case where the menu trigger should really display only the icon, `.labelStyle(.iconOnly)` can be used.
- Never use `onTapGesture()` unless you specifically need tap location or tap count. All other tappable elements should be a `Button`.
- If `onTapGesture()` must be used, make sure to add `.accessibilityAddTraits(.isButton)` or similar so it can be read by VoiceOver correctly.

View File

@@ -0,0 +1,39 @@
# Using modern SwiftUI API
- Always use `foregroundStyle()` instead of `foregroundColor()`.
- Always use `clipShape(.rect(cornerRadius:))` instead of `cornerRadius()`.
- Always use the `Tab` API instead of `tabItem()`.
- Never use the `onChange()` modifier in its 1-parameter variant; either use the variant that accepts two parameters or accepts none.
- Do not use `GeometryReader` if a newer alternative works: `containerRelativeFrame()`, `visualEffect()`, or the `Layout` protocol. Flag `GeometryReader` usage and suggest the modern alternative.
- When designing haptic effects, prefer using `sensoryFeedback()` over older UIKit APIs such as `UIImpactFeedbackGenerator`.
- Use the `@Entry` macro to define custom `EnvironmentValues`, `FocusValues`, `Transaction`, and `ContainerValues` keys. This replaces the legacy pattern of manually creating a type conforming to (for example) `EnvironmentKey` with a `defaultValue`, then extending `EnvironmentValues` with a computed property.
- Strongly prefer `overlay(alignment:content:)` over the deprecated `overlay(_:alignment:)`. For example, use `.overlay { Text("Hello, world!") }` rather than `.overlay(Text("Hello, world!"))`.
- Never use `.navigationBarLeading` and `.navigationBarTrailing` for toolbar item placement; they are deprecated. The correct, modern placements are `.topBarLeading` and `.topBarTrailing`.
- Prefer to rely on automatic grammar agreement when dealing with English, French, German, Portuguese, Spanish, and Italian. For example, use `Text("^[\(people) person](inflect: true)")` to show a number of people.
- You can fill and stroke a shape with two chained modifiers; you do *not* need an overlay for the stroke. The overlay was required previously, but this is fixed in iOS 17 and later.
- When referencing images from an asset catalog, prefer the generated symbol asset API when the project is configured to use them: `Image(.avatar)` rather than `Image("avatar")`.
- When targeting iOS 26 and later, SwiftUI has a native `WebView` view type that replaces almost all uses of hand-wrapped `WKWebView` inside `UIViewRepresentable`. To use it, make sure to include `import WebKit`.
- `ForEach` over an `enumerated()` sequence should not convert to an array first. Use `ForEach(items.enumerated(), id: \.element.id)` directly.
- When hiding scroll indicators, use `.scrollIndicators(.hidden)` rather than `showsIndicators: false` in the initializer.
- Never use `Text` concatenation with `+`.
For example, the usage of `+` here is bad and deprecated:
```swift
Text("Hello").foregroundStyle(.red)
+
Text("World").foregroundStyle(.blue)
```
Instead, use text interpolation like this:
```swift
let red = Text("Hello").foregroundStyle(.red)
let blue = Text("World").foregroundStyle(.blue)
Text("\(red)\(blue)")
```
## Using ObservableObject
If using `ObservableObject` is absolutely required for example if you are trying to create a debouncer using a Combine publisher you should always make sure `import Combine` is added. This was previously provided through SwiftUI, but that is no longer the case.

View File

@@ -0,0 +1,43 @@
# Data flow, shared state, and property wrappers
It is important that SwiftUI body code and logic code be kept separate in order to make code easier to read, write, and maintain. That usually means placing code into methods rather than inline in the `body` property, but often also means carving functionality out into separate `@Observable` classes.
These rules help ensure code is efficient and works well in the long term.
## Shared state
- `@Observable` classes must be marked `@MainActor` unless the project has Main Actor default actor isolation. Flag any `@Observable` class missing this annotation.
- All shared data should use `@Observable` classes with `@State` (for ownership) and `@Bindable` / `@Environment` (for passing).
- Strongly prefer not to use `ObservableObject`, `@Published`, `@StateObject`, `@ObservedObject`, or `@EnvironmentObject` unless they are unavoidable, or if they exist in legacy/integration contexts when changing architecture would be complicated.
## Local state
- `@State` should be marked `private` and only owned by the view that created it.
- If a view stores a class instance that contains expensive-to-recompute data, e.g. `CIContext`, it can be stored using `@State` even though it is not an observable object. This effectively uses `@State` as a cache storing something persistently, but not doing any change tracking on it since it's not an observable object.
## Bindings
- Strongly prefer to avoid creating bindings using `Binding(get:set:)` in view body code. It is much cleaner and simpler to use a binding provided by `@State`, `@Binding` or similar, then use `onChange()` to trigger any effects.
- If the user needs to enter a number into a `TextField`, bind the `TextField` to a numeric value such as `Int` or `Double`, then use its `format` initializer like this: `TextField("Enter your score", value: $score, format: .number)`. Apply either `.keyboardType(.numberPad)` (for integers) or `.keyboardType(.decimalPad)` (for floating-point numbers) as appropriate. Using the modifier alone is *not* sufficient.
## Working with data
- Prefer to make structs conform to `Identifiable` rather than using `id: \.someProperty` in SwiftUI code.
- Never attempt to use `@AppStorage` inside an `@Observable` class, even if marked `@ObservationIgnored`  it will *not* trigger view updates when a change happens.
## SwiftData
- If you only need the number of items matching a query, consider `ModelContext.fetchCount()` with a fetch descriptor. This will *not* live update if the data changes unless something else triggers the update, such as `@Query`, so it should be used carefully.
For more help with SwiftData, suggest the [SwiftData Pro agent skill](https://github.com/twostraws/swiftdata-agent-skill).
## If the project uses SwiftData with CloudKit
- Never use `@Attribute(.unique)`.
- Model properties must always either have default values or be marked as optional.
- All relationships must be marked optional.

View File

@@ -0,0 +1,32 @@
# Design
## Creating a uniform design in this app
Prefer to place standard fonts, sizes, colors, stack spacing, padding, rounding, animation timings, and more into a shared enum of constants, so they can be used by all views. This allows the apps design to feel uniform and consistent, and be adjusted easily.
## Requirements for flexible, accessible design
- Never use `UIScreen.main.bounds` to read available space; prefer alternatives such as `containerRelativeFrame()`, or `visualEffect()` as appropriate, or (if there is no alternative) `GeometryReader`.
- Prefer to avoid fixed frames for views unless content can fit neatly inside; this can cause problems across different device sizes, different Dynamic Type settings, and more. Giving frames some flexibility is usually preferred.
- Apples minimum acceptable tap area for interactions on iOS is 44x44. Ensure this is strictly enforced.
## Standard system styling
- Strongly prefer to use `ContentUnavailableView` when data is missing or empty, rather than designing something custom.
- When using `searchable()`, you can show empty results using `ContentUnavailableView.search` and it will include the search term they used automatically theres no need to use `ContentUnavailableView.search(text: searchText)` or similar.
- If you need an icon and some text placed horizontally side by side, prefer `Label` over `HStack`.
- Prefer system hierarchical styles (e.g. secondary/tertiary) over manual opacity when possible, so the system can adapt to the correct context automatically.
- When using `Form`, wrap controls such as `Slider` in `LabeledContent` so the title and control are laid out correctly.
- `LabeledContent` also works outside `Form` for any title-value display; it might be necessary to define a custom `LabeledContentStyle` for consistent layout across views.
- When using `RoundedRectangle`, the default rounding style is `.continuous` there is no need to specify it explicitly.
## Ensuring designs work for everyone
- Use `bold()` instead of `fontWeight(.bold)`, because using `bold()` allows the system to choose the correct weight for the current context.
- Only use `fontWeight()` for weights other than bold when there's an important reason - scattering around `fontWeight(.medium)` or `fontWeight(.semibold)` is counterproductive.
- Avoid hard-coded values for padding and stack spacing unless specifically requested.
- Avoid UIKit colors (`UIColor`) in SwiftUI code; use SwiftUI `Color` or asset catalog colors.
- The font size `.caption2` is extremely small, and is generally best avoided. Even the font size `.caption` is on the small side, and should be used carefully.

View File

@@ -0,0 +1,9 @@
# Hygiene
- If the project requires secrets such as API keys, never include them in the repository.
- Code comments and documentation comments should be present where the logic isn't self-evident.
- Unit tests should exist for core application logic. UI tests only where unit tests are not possible.
- `@AppStorage` must never be used to store usernames, passwords, or other sensitive data. Use the keychain for that.
- If SwiftLint is configured, it should return no warnings or errors.
- If the project uses Localizable.xcstrings, prefer to add user-facing strings using symbol keys (e.g. “helloWorld”) in the string catalog with `extractionState` set to "manual", accessing them via generated symbols such as `Text(.helloWorld)`. Offer to translate new keys into all languages supported by the project.
- If the Xcode MCP is configured, prefer its tools over generic alternatives. For example, `RenderPreview` is able to capture images of rendered SwiftUI previews for examination, and `DocumentationSearch` can search Apples documentation for latest usage instructions.

View File

@@ -0,0 +1,14 @@
# Navigation and presentation
- Use `NavigationStack` or `NavigationSplitView` as appropriate; flag all use of the deprecated `NavigationView`.
- Strongly prefer to use `navigationDestination(for:)` to specify destinations; flag all use of the old `NavigationLink(destination:)` pattern where it should be replaced.
- Never mix `navigationDestination(for:)` and `NavigationLink(destination:)` in the same navigation hierarchy; it causes significant problems.
- `navigationDestination(for:)` must be registered once per data type; flag duplicates.
## Alerts, confirmation dialogs, and sheets
- Always attach `confirmationDialog()` to the user interface that triggers the dialog. This allows Liquid Glass animations to move from the correct source.
- If an alert has only a single “OK” button that does nothing but dismiss the alert, it can be omitted entirely: `.alert("Dismiss Me", isPresented: $isShowingAlert) { }`.
- If a sheet is designed to present an optional piece of data, prefer `sheet(item:)` over `sheet(isPresented:)` so the optional is safely unwrapped.
- When using `sheet(item:)` with a view that accepts the item as its only initializer parameter, prefer `sheet(item: $someItem, content: SomeView.init)` over `sheet(item: $someItem) { someItem in SomeView(item: someItem) }`.

View File

@@ -0,0 +1,46 @@
# Performance
- When toggling modifier values, prefer ternary expressions over if/else view branching to avoid `_ConditionalContent`, preserve structural identity, and avoid repeatedly recreating underlying platform views.
- Avoid `AnyView` unless absolutely required. Use `@ViewBuilder`, `Group`, or generics instead.
- If a `ScrollView` has an opaque, static, and solid background, prefer to use `scrollContentBackground(.visible)` to improve scroll-edge rendering efficiency.
- It is more efficient to break views up by making dedicated SwiftUI views rather than place them into computed properties or methods. Using `@ViewBuilder` on a property or method does not solve this; breaking views up is strongly preferred.
- Always ensure view initializers are kept as small and simple as possible, avoiding any non-trivial work. Flag any work that can be moved into a `task()` modifier to be run when the view is shown.
- Similarly, assume each views `body` property is called frequently  if logic such as sorting or filtering can be moved out of there easily, it should be.
- Avoid creating properties to store formatters such as `DateFormatter` unless they are required. A more natural approach is to use `Text` with a format, like this: `Text(Date.now, format: .dateTime.day().month().year())` or `Text(100, format: .currency(code: "USD"))`.
- Avoid expensive inline transforms in `List`/`ForEach` initializers (e.g. `items.filter { ... }`) when they are repeated often.
- Prefer deriving transformed data from the source-of-truth using `let`, or caching in `@State`. However, do not cache derived collections in `@State` unless you also own explicit invalidation logic to avoid stale UI.
- For large data sets in `ScrollView`, use `LazyVStack`/`LazyHStack`; flag eager stacks with many children.
- Prefer using `task()` over `onAppear()` when doing async work, because it will be cancelled automatically when the view disappears.
- Avoid storing escaping `@ViewBuilder` closures on views when possible; store built view results instead.
Example:
```swift
// Anti-pattern: stores an escaping closure on the view.
struct CardView<Content: View>: View {
let content: () -> Content
var body: some View {
VStack(alignment: .leading) {
content()
}
.padding()
.background(.ultraThinMaterial)
.clipShape(.rect(cornerRadius: 8))
}
}
// Preferred: store the built view value; the synthesized init handles calling the builder.
struct CardView<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading) {
content
}
.padding()
.background(.ultraThinMaterial)
.clipShape(.rect(cornerRadius: 8))
}
}
```

View File

@@ -0,0 +1,56 @@
# Swift
- Prefer Swift-native string methods over Foundation equivalents: use `replacing("a", with: "b")` not `replacingOccurrences(of: "a", with: "b")`.
- Prefer modern Foundation API: `URL.documentsDirectory` instead of `FileManager` directory lookups, `appending(path:)` to append strings to a URL.
- Never use C-style number formatting like `String(format: "%.2f", value)`. Use `Text(value, format: .number.precision(.fractionLength(2)))` or similar `FormatStyle` APIs.
- Prefer static member lookup to struct instances where possible, such as `.circle` rather than `Circle()`, and `.borderedProminent` rather than `BorderedProminentButtonStyle()`.
- Avoid force unwraps (`!`) and force `try` unless the failure is truly unrecoverable, and even then prefer using `fatalError()` with a clear description. If possible, use `if let`, `guard let`, nil-coalescing, or `try?`/`do-catch`.
- Filtering text based on user-input must be done using `localizedStandardContains()` as opposed to `contains()` or `localizedCaseInsensitiveContains()`.
- Strongly prefer `Double` over `CGFloat`, except when using optionals or `inout`; Swift is able to bridge the two freely except in those two cases.
- If you want to count array objects that match a predicate, always use `count(where:)` rather than `filter()` followed by `count`.
- Prefer `Date.now` over `Date()` for clarity.
- When `import SwiftUI` is already in a file, you do not need to add `import UIKit` or `import AppKit` to access things like `UIImage` or `NSImage` they are imported automatically on the appropriate platform.
- When dealing with the names of people, strongly prefer to use `PersonNameComponents` with modern formatting over simple string interpolation such as `Text("\(firstName) \(lastName)")`.
- If a given type of data is repeatedly sorted using an identical closure, e.g. `books.sorted { $0.author < $1.author }`, prefer to make the type in question conform to `Comparable` so the sort order is centralized.
- Prefer to avoid manual date formatting strings if possible. If manual date formatting *is* used for user display, at least make sure to use “y” rather than “yyyy” for years, so the year value is correct in all localizations. If the purpose is data exchange with an API, this rule does not apply.
- When trying to convert a string to a date, prefer the modern `Date` initializer API such as `Date(myString, strategy: .iso8601)`.
- Flag instances where errors triggered by a user action are swallowed silently, e.g. using `print(error.localizedDescription)` rather than showing an alert or similar.
- Prefer `if let value {` shorthand over `if let value = value {`.
- Omit return for single expression functions. `if` and `switch` can be used as expressions when returning values and assigning to variables.
For example, this kind of code:
```swift
var tileColor: Color {
if isCorrect {
return .green
} else {
return .red
}
}
```
Should be written like this:
```swift
var tileColor: Color {
if isCorrect {
.green
} else {
.red
}
}
```
## Swift Concurrency
- If an API offers both modern `async`/`await` equivalents and older closure-based variants, always prefer the `async`/`await` versions.
- Never use Grand Central Dispatch (`DispatchQueue.main.async()`, `DispatchQueue.global()`, etc.). Always use modern Swift concurrency (`async`/`await`, actors, `Task`).
- Never use `Task.sleep(nanoseconds:)`; use `Task.sleep(for:)` instead.
- Flag any mutable shared state that isn't protected by an actor or `@MainActor`, unless the project is configured to use MainActor default actor isolation.
- Assume strict concurrency rules are being applied; flag `@Sendable` violations and data races.
- When evaluating `MainActor.run()`, check whether the project has its default actor isolation set to Main Actor first, because `MainActor.run()` might not be needed.
- `Task.detached()` is often a bad idea. Check any usage extremely carefully.
For more help with Swift concurrency, suggest the [Swift Concurrency Pro agent skill](https://github.com/twostraws/swift-concurrency-agent-skill).

View File

@@ -0,0 +1,36 @@
# SwiftUI Views
- Strongly prefer to avoid breaking up view bodies using computed properties or methods that return `some View`, even if `@ViewBuilder` is used. Extract them into separate `View` structs instead, placing each into its own file.
- Flag `body` properties that are excessively long; they should be broken into extracted subviews.
- If the user has created a handful of small, private helper `some View` properties for structural readability, and they both belong to the same concern as `body` and would fit in `body` at an acceptable length if inlined, these can be left alone. Otherwise, they should be extracted to new `View` structs.
- Button actions should be extracted from view bodies into separate methods, to avoid mixing layout and logic.
- Similarly, general business logic should not live inline in `task()`, `onAppear()` or elsewhere in `body`.
- Prefer to place view logic into view models or similar, so it can be tested. For more help with testing, suggest the [Swift Testing Pro agent skill](https://github.com/twostraws/swift-testing-agent-skill).
- Each type (struct, class, enum) should be in its own Swift file. Flag files containing multiple type definitions.
- Unless a full-screen editing experience is required, prefer using `TextField` with `axis: .vertical` to using `TextEditor`, because it allows placeholder text. If a specific minimum height is required for `TextField`, use something like `lineLimit(5...)`.
- If a button action can be provided directly as an `action` parameter, do so. For example: `Button("Label", systemImage: "plus", action: myAction)` is preferred over `Button("Label", systemImage: "plus") { action() }`.
- When rendering SwiftUI views to images, strongly prefer `ImageRenderer` over `UIGraphicsImageRenderer`.
- `#Preview` should be used for previews, not the legacy `PreviewProvider` protocol.
- When using `TabView(selection:)`, use a binding to a property that stores an enum rather than an integer or string. For example, `Tab("Home", systemImage: "house", value: .home)` is better than `Tab("Home", systemImage: "house", value: 0)`.
- Strongly prefer to avoid breaking up view bodies using computed properties or methods that return `some View`, even if `@ViewBuilder` is used. Extract them into separate `View` structs instead, placing each into its own file. (Yes, this is repeated, but its so important it needs to be mentioned twice.)
## Animating views
- Strongly prefer to use the `@Animatable` macro over creating `animatableData` manually  the macro automatically adds conformance to the `Animatable` protocol and creates the correct `animatableData` property. If some properties should not or cannot be animated (e.g. Booleans, integers, etc), mark them `@AnimatableIgnored`.
- Never use `animation(_ animation: Animation?)`; always provide a value to watch, such as `.animation(.bouncy, value: score)`.
- Chaining animations must be done using a `completion` closure passed to `withAnimation()`, rather than trying to execute multiple `withAnimation()` calls using delays.
For example:
```swift
Button("Animate Me") {
withAnimation {
scale = 2
} completion: {
withAnimation {
scale = 1
}
}
}
```

View File

@@ -0,0 +1,49 @@
# Writing for Interfaces Skill
## Install
```bash
npx skills add andrewgleave/skills --skill writing-for-interfaces --global
```
## Example Prompt
```text
/writing-for-interfaces Review and evaluate all UI copy for clarity, purpose, and consistency.
```
## Skill Structure
This repository follows the **Agent Skills** open standard. Each skill is self-contained with its own logic, workflow, and reference materials.
```text
writing-for-interfaces/
├── SKILL.md — Core instructions, principles, and voice/tone guidance
├── references/
│ └── patterns.md — Detailed guidance for common interface patterns
└── README.md
```
## How it Works
When activated, the agent applies a voice-first workflow:
1. **Establish voice**: Search for an existing voice definition in project files (`CLAUDE.md / AGENTS.md`, style guides, design docs). If none exists or the existing copy is inconsistent, walk the user through defining one — what the product does, who it's for, where it's used, and what personality traits define it. An established and consistent voice is the foundation for all copy decisions.
2. **Evaluate the request**: Determine whether the task is new copy, a review, a rewrite, or terminology work and identify which interface patterns apply.
3. **Apply voice and principles**: Check that copy sounds like the defined voice. Dial tone qualities up or down for the situation and then apply the core principles.
4. **Evaluate**: Consult the patterns reference for situation-specific guidance on structure, tone, and common pitfalls.
5. **Apply changes**: Rewrite existing copy inline or draft from scratch. Show original → rewrite with a brief rationale tied to voice and principles. Prioritise changes that confuse or block users before polish.
6. **Update terminology reference**: Flag terminology drift and suggest word list entries to keep voice and phrasing consistent across the interface. The user should be able to review the changes and approve or reject them.
## Sources
Many principles are distilled from Apple's interface writing guidance and generalised for product interfaces more broadly:
- [**Human Interface Guidelines** — Writing](https://developer.apple.com/design/human-interface-guidelines/writing/)
- [**Human Interface Guidelines** — Alerts](https://developer.apple.com/design/human-interface-guidelines/alerts/)
- [**Human Interface Guidelines** — Accessibility](https://developer.apple.com/design/human-interface-guidelines/accessibility/)
- [**WWDC 2019** — Writing Great Accessibility Labels](https://developer.apple.com/videos/play/wwdc2019/254/)
- [**WWDC 2022** — Writing for Interfaces](https://developer.apple.com/videos/play/wwdc2022/10037/)
- [**WWDC 2024** — Adding Personality to Your App Through UX Writing](https://developer.apple.com/videos/play/wwdc2024/10140/)
- [**WWDC 2025** — Make a Big Impact with Small Writing Changes](https://developer.apple.com/videos/play/wwdc2025/404/)
- [**Apple Style Guide**](https://help.apple.com/applestyleguide/)

View File

@@ -0,0 +1,299 @@
---
name: writing-for-interfaces
description: >
Use when someone asks to write, rewrite, review, or improve text that appears inside a
product or interface. Examples: "review the UX copy", "is there a better way to phrase
this", "rewrite this error message", "write copy for this screen/flow/page", reviewing
button labels, improving CLI output messages, writing onboarding copy, settings
descriptions, or confirmation dialogs. Trigger whenever the request involves wording shown
to end users inside software — apps, web, CLI, email notifications, modals, tooltips,
empty states, or alerts. Also trigger for vague requests like "review the UX" where
interface copy review is implied. Do NOT trigger for content marketing, blog posts, app
store listings, API docs, brand guides, cover letters, or interview questions — this is a
technical writing skill for interface language.
context: fork
---
# Writing for Interfaces
Good interface writing is invisible. When words work seamlessly with design, people don't
notice them.
Writing should be part of the design process from the start, not something filled in at the
end. When words are considered alongside layout, interaction, and visual design, the result
feels seamless. When they're an afterthought, product experiences feel stitched together.
Every piece of text in an interface is a small act of communication: it should respect the
person's time, meet them where they are, and help them move forward.
---
## When triggered
### Step 1: Establish voice and personality
Voice is the foundation. All copy decisions — what to say, how to say it, what to leave
out — flow from a clear understanding of who this product is, who it's for, and how it
should sound. Without a defined voice, copy becomes inconsistent and the product loses coherency.
**Search for an existing voice definition.** Check for:
- A `CLAUDE.md`, `AGENTS.md`, or similar project config that defines voice and/or tone
- A style guide, design system documentation, or brand guidelines
- A word list or terminology reference
**If a voice definition exists**, use it as the lens for all copy work. If the copy you're
working on drifts from it, flag the inconsistency.
**If no voice definition exists**, infer the current voice from existing copy. Look for
patterns: formal or casual? Technical or plain? Warm or matter-of-fact? If the copy is
inconsistent or insufficient to infer from, help the user establish a voice before writing.
#### Establishing voice through conversation
Walk the user through these questions:
1. **What does the product do and who is it for?** A banking app for professionals and a
savings app for kids serve similar purposes but should sound completely different. The
audience determines vocabulary, complexity, and register.
2. **Why do people use it, and where?** Someone using a health app during a crisis needs
calm clarity. Someone browsing a game at home can handle playfulness. The context of use
— physical environment, emotional state, competing attention — shapes how much text
people can absorb and what tone is appropriate.
3. **Imagine the product as a person. What 34 personality traits make them unique?**
Brainstorm freely, group similar words into themes, discard table-stakes traits ("not
confusing"), and keep the ones that genuinely differentiate the product's personality.
4. **Look for productive tensions.** The best voice definitions have qualities that push
against each other. "Friendly" and "concise" create a useful tension — these become the
dials you turn when adjusting tone for different situations.
5. **Capture it.** Suggest the user persist the voice definition somewhere durable
(`AGENTS.md`, `CLAUDE.md` or style guide document) so it persists across sessions. A word list pairs well with this and should be stored in the same file.
### Step 2: Evaluate the request
Identify what kind of copy work is needed:
- **New copy**: Writing from scratch for a screen, flow, or component.
- **Review**: Evaluating existing copy for clarity, consistency, and tone.
- **Rewrite**: Improving specific text that isn't working.
- **Terminology**: Building or maintaining a word list.
Then identify which interface patterns are involved
and consult `references/patterns.md` for the relevant sections.
### Step 3: Apply voice, then principles
For every piece of copy, work in this order:
1. **Does it sound like the voice?** Read it against the 34 qualities. If you read it
aloud, would you recognise it as coming from this product?
2. **Which qualities need dialing up or down for this situation?** Think of each voice
quality as a dial. A celebratory moment turns up warmth; an error turns up clarity and
dials back friendliness.
3. **Apply the core principles** (purpose, anticipation, context, empathy — detailed below).
4. **Apply the craft rules** (remove filler, avoid repetition, be specific — detailed below).
The ordering is deliberate and encodes a precedence chain: **clarity > voice > craft
rules.** Clarity always wins — if voice gets in the way of someone understanding what to
do, strip it back. Voice comes next — it shapes how things sound, and a craft rule should
never cut a word or restructure a phrase in a way that undermines the established voice.
Craft rules are voice-filtered heuristics, not absolutes. Always cross-check craft edits
against the voice before committing them.
### Step 4: Deliver changes
Work through the copy element by element — title, body, buttons, labels — showing the
original, then the rewrite, with a brief rationale tied to voice and principles. Prioritise
changes that confuse or block users before polish. When reviewing across a flow, flag
terminology inconsistencies and suggest word list entries at the end.
---
## Voice and tone
### Voice vs. tone
**Voice** is the consistent personality of the product — the 34 qualities that define how
it always sounds. These don't change.
**Tone** is how the voice adapts to the situation. Think of each voice quality as a dial you
can turn up or down depending on the moment:
- Celebrating a milestone? Turn up warmth, dial back brevity.
- Reporting an error? Turn up clarity and helpfulness, dial back friendliness.
- Onboarding a new user? Balance helpfulness with warmth.
- Confirming a destructive action? Turn up directness, keep calm and concise.
### Applying tone in practice
For each situation, decide which voice qualities need emphasis and which should recede.
**Example**: For an error where someone can't connect to the network, clarity and
helpfulness go way up. Simplicity stays moderate because they need the most important
details. Friendliness dials back because getting them unstuck matters more than sounding
warm.
### Where personality belongs
Personality shines in moments where there's room for it — welcome screens, milestones,
empty states. In error messages, destructive actions, and critical flows, dial voice back
and let clarity lead. The precedence chain from Step 3 applies: clarity first, always.
---
## Core principles
Purpose, Anticipation, Context, Empathy — a framework for what to write, how to write it,
and when. Apply through the lens of your voice.
### 1. Purpose
Before writing, answer: **what is the single most important thing the person needs to know
right now?**
- **Use information hierarchy.** Headlines and buttons carry the primary message; supporting
text fills in detail. If someone reads only headers and buttons, they should understand
the situation.
- **Cut what doesn't serve this moment.** Move it elsewhere or remove it. When a screen
tries to do too much, return to its purpose and strip away everything else.
- **Tell people the purpose.** When introducing a feature, tell them why it exists and why
it matters to them.
### 2. Anticipation
Think of the interface as a conversation. In any good conversation there's a natural back
and forth — listening, responding, anticipating what the other person needs to hear next.
- After telling someone about a problem, tell them how to fix it. "Can't connect to
Wi-Fi" → "Can't connect to Wi-Fi. Check your connection and try again."
- After asking someone to do something, make it obvious how to do it. "Verify your
identity" → "Verify your identity" with a clear button or link to start the process.
- After someone completes something, acknowledge it and point forward. "Password changed"
→ "Password changed. You can now sign in with your new password."
- **Lead with the "why".** Put the benefit or reason before the instruction: "To [benefit],
[instruction]." Front-loading motivation makes the instruction feel like a reasonable ask
instead of a demand.
### 3. Context
People use products in wildly different circumstances. The usage context shapes the writing.
- **Think outside the app.** Consider the physical and emotional situation.
- **Match density to available attention.** Mid-task text should be ultra-brief. Setup flows
can afford more.
- **Timing matters.** Show information when it's relevant, not before. Place instructions
where the person is looking.
- **Write for the device.** Describe gestures correctly ("tap" not "click" on touch). Phones
demand brevity; shared screens (TVs) need large, scannable text.
### 4. Empathy
Write for everyone who might use this product — different abilities, languages, cultures,
technical fluency, and emotional states.
- **Use plain, direct language.** Avoid jargon, idioms, and culturally specific references.
- **Design for accessibility from the start.** Labels, descriptions, and alt text aren't
afterthoughts — they're the entire experience for some people. See patterns reference for
detailed guidance.
- **Use inclusive, neutral language.** Avoid unnecessary references to gender, age, or
ability.
- **Consider localisation.** Write short copy, not compressed long copy. Account for text
expansion and RTL languages.
---
## Writing craft
Practical editing moves that tighten copy. Apply after confirming voice and tone are right.
### Remove filler words
Interface text has no minimum word count. Every word must earn its place. But before cutting
a word, check whether it's doing voice work. A word that's "filler" by general craft rules
may be load-bearing for the voice — "yet" in "Nothing here yet" carries warmth and calm,
and removing it makes the empty state blunter. **The test:** remove the word; if neither
meaning nor intentional tone changes, cut it.
- **Adverbs/adjectives**: "Simply enter your license plate" → "Enter your license plate."
Words like "simply," "quickly," "easily," "just," "successfully" often promise something
you can't guarantee. Keep words that genuinely clarify ("Feed your pets automatically").
- **Interjections**: "Oops!", "Uh oh!" in errors trivialise the problem. Cut them.
- **Pleasantries**: "Sorry" and "please" sound insincere in automated messages. Use only
when they genuinely add warmth.
- **Punctuation**:
- **Exclamation marks**: rare. Reserve for genuinely celebratory moments.
- **Dashes** (en/em): avoid in interface copy. They interrupt scanning. Break into
separate lines or sentences instead.
- **Ellipsis**: only for processes in progress ("Loading..."), not trailing thoughts.
### Avoid repetition
Combine overlapping ideas into one clear statement. Each element on screen should add new
information. When headline and body say the same thing in different words, collapse them.
"We're running late. Your delivery driver won't make it on time. They'll be there in 10
minutes." → "Delivery delayed 10 minutes. Check the app for your driver's location."
### Be specific, not vague
- Name the thing: "Can't open 'Quarterly Report.pdf'" not "Can't open this file."
- Name the action: "Cancel Subscription" / "Keep Subscription" not "Yes" / "No."
- Give real information: "Your card ending in 4242 was declined" not "There was a payment
error."
### Keep a word list
Decide what you call things and stick to it. If it's "alias" on one screen, don't use
"username" on another. A word list is a simple table: **Use** / **Don't use** /
**Definition**. Button labels are especially good entries — if "Next" advances through a
flow, use "Next" everywhere.
### Pronouns and perspective
"Favorites" conveys the same message as "Your Favorites." Avoid "we" — it obscures what
actually happened ("We're having trouble..." → "Unable to load content").
### Sweat the details
Correct spelling, grammar, and punctuation build trust. Adopt capitalisation rules aligned
with the voice (title case = formal, sentence case = casual) and apply consistently. Write
for the space available — if copy needs to be short, write a short sentence, don't compress
a long one.
### Write for dynamic content
Templated strings (`"${count} items selected"`) are interface copy too. Write them so every
possible output reads naturally:
- **Handle zero, one, and many.** "No results," "1 result," "24 results" — not a single
template that produces "0 results found."
- **Read it with real values.** Substitute short and long names, small and large numbers.
"Welcome back, Christopher-Montgomery!" might break layout; "3 seconds ago" and "2 days
ago" should both read naturally.
- **Keep templates simple.** If a string needs complex branching to read well, the design
may be asking too much of a single element.
### Build language patterns
Define patterns for common moments — how flows begin ("Get Started"), advance ("Next" or
"Continue" — pick one), and end ("Done"). Use them consistently.
---
## The simplest test
Read your writing out loud. If it sounds like how you'd explain something to a friend —
clear, natural, no filler — it's probably good. If it sounds like a robot, a legal
document, or an essay, keep refining.
---
## Patterns reference
For detailed guidance on alerts, errors, empty states, onboarding, notifications,
accessibility labels, destructive actions, buttons, and instructional copy — see
`references/patterns.md`.

View File

@@ -0,0 +1,297 @@
# Interface copy patterns
Detailed guidance for common interface writing situations. Each pattern should be applied
through the lens of your product's voice and tone — the voice stays consistent, the tone
adapts to the situation.
These patterns cover common cases, not every interface element. For anything not listed here,
apply the core principles and voice framework from the main skill document.
## Table of contents
1. [Alerts and dialogs](#alerts-and-dialogs)
2. [Error messages](#error-messages)
3. [Destructive actions](#destructive-actions)
4. [Empty states](#empty-states)
5. [Onboarding and setup flows](#onboarding-and-setup-flows)
6. [Notifications](#notifications)
7. [Accessibility labels](#accessibility-labels)
8. [Buttons and actions](#buttons-and-actions)
9. [Instructional and inline copy](#instructional-and-inline-copy)
10. [Settings and preferences](#settings-and-preferences)
---
## Alerts and dialogs
Alerts interrupt what someone is doing. Every alert must justify that cost by delivering
information the person genuinely needs right now.
### When to use an alert
- To confirm a significant or irreversible action.
- To request access to sensitive data (location, contacts, camera).
- To report an error that blocks progress.
- To notify of an event or situation requiring immediate attention.
### When NOT to use an alert
- For non-essential information (use inline messaging or banners).
- For lengthy content or complex choices (use a dedicated screen).
- For problems you could have prevented (validate input inline).
- For technical diagnostics the person can't act on.
- For common, undoable actions — even destructive ones. People who delete an email intend
to discard it and can undo the action; they don't need an alert every time.
- At app launch. If something's wrong at startup (like no network), show cached or
placeholder data with a nonintrusive label describing the problem.
### Structure
A good alert answers: **What happened? Why? What now?**
- **Title**: The main point in one short sentence. If someone reads only the title and
buttons, they should understand the situation. Sentence-style caps for complete sentences;
title-style caps for fragments.
- **Body** (optional): 12 sentences of additional context. Only include if it adds
information the title doesn't cover. Don't use the body to explain what the buttons do —
if the title and buttons are clear, the body isn't needed.
- **Actions**: Specific verb labels (see [Buttons and actions](#buttons-and-actions)).
### Tone guidance
Alerts are interruptions in moments that range from routine to critical. Dial up clarity
and directness. Dial back personality — this isn't the place for the voice to shine, it's
the place for the voice to stay calm and get out of the way.
### Checklist
- Could this be communicated without an interruption?
- Can someone understand it from title and buttons alone?
- Are button labels specific actions, not generic confirmations?
- Is the body actually adding information?
**Before:**
> Title: "App cannot open this file"
> Body: "You may need to download the latest update."
> Buttons: Yes / No
**After:**
> Title: "Can't Open 'Report.pdf'"
> Body: "Update the app to open this file format."
> Buttons: Update / Cancel
---
## Error messages
Errors are moments of friction. Your job is to get the person unstuck as fast as possible.
### Principles
1. **Say what happened** in plain language. Name the specific thing: "Can't connect to
Wi-Fi" not "Network error."
2. **Explain why** if it helps — skip if the cause is obvious or irrelevant.
3. **Tell them what to do next.** Every error should have a clear path forward. Display
errors close to the problem.
### What to avoid
- Technical jargon and error codes the person can't act on.
- Blaming the person ("invalid input"). Instruct instead: "Use only letters for your name."
- Interjections ("Oops!", "Uh oh!") — they trivialise the problem.
- Vague non-information: "Something went wrong. Please try again."
- "Please" and "sorry" as reflexive padding.
- Robotic messages with no helpful information, like "Invalid name."
### Tone guidance
Errors can be frustrating. Dial up clarity and helpfulness. Dial back friendliness — calm,
direct language respects the person's situation more than forced warmth. If language alone
can't address an error that's likely to affect many people, use that as a signal to rethink
the interaction.
**Before:**
> "Oops! You can't do that. Error code 1234567. Please try again."
> Buttons: Okay / Cancel
**After:**
> Title: "Billing Problem"
> Body: "To continue your subscription, add a new payment method."
> Buttons: Add Payment Method / Not Now
---
## Destructive actions
When an action can't be undone, the writing must be proportionally careful.
### Principles
- **Name the specific thing being destroyed**: "Delete 'Vacation Photos' album?" not
"Delete this item?"
- **Make consequences explicit**: "You'll lose all 847 photos in this album."
- **Label buttons with the actual action**: "Delete Album" / "Keep Album" — not "Confirm"
/ "Cancel." (See [Buttons and actions](#buttons-and-actions).)
- **Avoid double-negative confusion.** "Cancel Cancellation" is a dark pattern. Write:
"Cancel Platinum Subscription?" with buttons "Cancel Subscription" / "Keep Subscription."
- **Use the destructive style** (e.g. red button) for actions the person didn't deliberately
initiate. When they chose the action (like Empty Trash), the confirmation doesn't need it.
- **Always include a Cancel button** as a clear, safe way out.
### Tone guidance
Dial up directness and specificity. Keep the voice calm and neutral. This is not a moment
for personality — it's a moment for clarity.
---
## Empty states
An empty state is a screen with no content yet — an opportunity to teach, guide, or
occasionally delight, but always with purpose.
### Principles
- **Tell the person what will appear here and how to make it happen**: "No Saved Episodes.
Save episodes you want to listen to later, and they'll show up here."
- **Match tone to context.** Completed to-do list: celebratory. Empty search result:
helpful, not whimsical.
- **Avoid idioms or humour that might not translate.**
- **Include a clear action** if possible: a button to create, add, or search.
- **Empty states are temporary** — don't put crucial information here.
### Tone guidance
Empty states are one of the best places for personality to shine through — especially
welcome screens and completed states. But make sure the content is useful and fits the
context. Education first, delight second.
---
## Onboarding and setup flows
Onboarding is your chance to welcome someone, explain the product's value, and help them get
started without wasting their time.
### Principles
- **Define the purpose of the whole flow and each screen.**
- **Lead with the why.** Tell people why you need what you're asking for.
- **Be honest about data and permissions**: explain how data will be used.
- **Welcome with warmth, but don't waste time.** One sentence capturing the product's value
beats three paragraphs.
- **Use consistent button labels.** "Next" on every screen, "Get Started" at the beginning,
"Done" at the end.
- **Each screen should say one thing.** Multiple ideas → multiple screens.
### Tone guidance
Onboarding is a warm moment. Dial up friendliness and helpfulness — the voice can shine here
more than almost anywhere else. But never sacrifice clarity for personality — people need to
understand what they're setting up.
---
## Notifications
Notifications reach people when they're doing something else.
### Principles
- **Lead with the key information**, not the instruction. "Your package arrives in 10
minutes" beats "Open the app to check delivery status."
- **Be specific**: "8 minutes to Home — take Audubon Ave, traffic is light" gives real
value. "Check your commute!" does not.
- **Respect attention.** If it's not time-sensitive or actionable, it probably shouldn't be
a notification.
- **One idea per notification.** Link to a screen for more detail.
- **Choose the right delivery method.** Alert for critical interruptions, banner for
informational, inline for contextual.
### Tone guidance
Notifications should feel like a helpful tap on the shoulder, not a demand for attention.
Keep the voice present but restrained. Match tone to urgency: a delayed delivery is
matter-of-fact; a milestone can be warmer.
---
## Accessibility labels
For screen reader users, accessibility labels _are_ the interface. Every interactive element
and every meaningful visual needs a thoughtful text label.
### Principles
- **Always add labels.** An unlabeled button reads as "button" — unusable. A person gives
an app about 30 seconds; if they can't access the functionality, they delete it.
- **Be succinct, but disambiguate when needed.** "Add" is usually enough; use "Add to cart"
when there are multiple "Add" buttons. Skip redundant context — in a music player, "Play"
is sufficient.
- **Don't include the element type.** Screen readers announce "button," "link," etc.
"Add button" produces "Add button, button."
- **Describe intent, not appearance.** An image label should convey meaning: "Person
meditating with relaxed arms and forefingers touching" — not "circular image, blue
background."
- **Update labels when state changes.** Play → Pause, etc.
- **Label loading states.** A spinner should announce "Loading."
- **Match richness to content.** Most labels should be succinct. But when the content
itself is expressive — stickers, emoji, illustrations — a richer description serves the
person better. A sticker of Cookie Monster might be labelled "Me happy face eat small
cookie, om nom nom" because that captures the spirit of what a sighted person sees. The
goal is an equivalent experience, not just a minimal one.
- **Use inclusive language.** "Person" rather than assumed gender.
- **Web:** Applies to `aria-label`, `aria-describedby`, and `alt` attributes.
---
## Buttons and actions
Buttons are the most-read text in any interface. People scan headers and buttons to
understand a screen — they may never read the body.
### Principles
- **Use specific verbs**: "Save Changes," "Send Message," "Download Report" — not "OK,"
"Submit," or generic "Done."
- **Match the label to surrounding text.** If the body says "pair your device," the button
should say "Start Pairing."
- **Paired choices must be clear independently**: "Keep Subscription" / "Cancel
Subscription" — not "Confirm" / "Cancel."
- **Destructive actions**: visually distinct (e.g. red), labelled with what they destroy.
- **Avoid "OK"** unless purely informational. "OK" is ambiguous — does it mean "do it" or
"I understand"?
- **Prefer verbs over "Yes" / "No."** The button labels alone should convey the choice.
- **Be consistent.** Add button labels to your word list.
---
## Instructional and inline copy
Field hints, tooltips, inline guidance, step descriptions, settings labels.
### Principles
- **Lead with the benefit**: "To keep your streak, solve today's crossword."
- **Be direct.** No "simply," no "quickly."
- **Place instructions where the person is looking.**
- **One instruction at a time.**
- **For text fields**: label clearly, use hint text for format examples
("name@example.com"). Show errors next to the field.
---
## Settings and preferences
Settings are utilitarian — people visit to find something specific and get out.
### Principles
- **Name settings plainly.**
- **Add a short description if the label isn't enough.** Describe what the setting does when
on — people infer the opposite.
- **Provide direct links** to navigate to a setting rather than describing its location.

View File

@@ -0,0 +1,15 @@
# Sources
This skill draws on:
- [Apple HIG: Writing](https://developer.apple.com/design/human-interface-guidelines/writing/)
- [Apple HIG: Alerts](https://developer.apple.com/design/human-interface-guidelines/alerts/)
- WWDC22: [Writing for Interfaces](https://developer.apple.com/videos/play/wwdc2022/10037)
— the PACE framework (Purpose, Anticipation, Context, Empathy)
- WWDC24: [Add Personality to Your App Through UX Writing](https://developer.apple.com/videos/play/wwdc2024/10140)
— voice/tone exercises, the dial metaphor
- WWDC25: [Make a Big Impact with Small Writing Changes](https://developer.apple.com/videos/play/wwdc2025/404)
— filler words, repetition, lead with the why, word lists
- WWDC19: [Writing Great Accessibility Labels](https://developer.apple.com/videos/play/wwdc2019/254)
— context-driven labelling, verbosity as a deliberate choice
- [Apple Style Guide](https://help.apple.com/applestyleguide/)

View File

@@ -1,369 +0,0 @@
# Skill — Audio Engine (useAudioEngine)
> Lis ce skill AVANT d'implémenter quoi que ce soit lié à l'audio.
## Responsabilité de ce module
L'audio engine gère 4 couches sonores indépendantes qui s'activent
en réponse aux événements du timer. Il ne connaît pas le timer
directement — il s'abonne à ses événements via useTimerSync.
**Objectif principal : zéro latence audio sur les transitions de phase.**
Les sons de phase doivent jouer dans les 50ms suivant l'événement.
---
## Architecture du module audio
```
src/features/audio/
types.ts
hooks/
useAudioEngine.ts ← moteur central expo-av
useAudioSettings.ts ← préférences utilisateur persistées
data/
tracks.ts ← catalogue musique (offline, paths locaux)
sounds.ts ← catalogue signaux de phase
components/
MusicPicker.tsx ← sélecteur d'ambiance musicale
SoundPicker.tsx ← sélecteur de signal de phase
AudioSettingsPanel.tsx ← panneau complet des réglages
index.ts
```
---
## Types
```typescript
// src/features/audio/types.ts
export type MusicAmbiance = 'ELECTRO' | 'HIP_HOP' | 'ROCK' | 'SILENCE'
export type MusicIntensity = 'LOW' | 'MEDIUM' | 'HIGH'
export type PhaseSound = 'beep' | 'whistle' | 'voice_go' | 'air_horn' | 'bell' | 'silence'
export interface AudioTrack {
id: string
ambiance: MusicAmbiance
intensity: MusicIntensity
asset: number // require('../assets/audio/...') — offline
bpm: number
durationMs: number
}
export interface AudioSettings {
musicEnabled: boolean
ambiance: MusicAmbiance
musicVolume: number // 0.0 → 1.0
soundsEnabled: boolean
soundsVolume: number // 0.0 → 1.0
hapticsEnabled: boolean
voiceEnabled: boolean // annonces vocales
overrideMuteSwitch: boolean // iOS : jouer même en mode silencieux
workPhaseSound: PhaseSound
restPhaseSound: PhaseSound
countdownSound: PhaseSound
completionSound: PhaseSound
}
export interface AudioState {
isLoaded: boolean
isMusicPlaying: boolean
currentAmbiance: MusicAmbiance
currentIntensity: MusicIntensity
error: string | null
}
export interface AudioActions {
preloadAll: () => Promise<void>
startMusic: (ambiance: MusicAmbiance, intensity: MusicIntensity) => Promise<void>
switchIntensity: (intensity: MusicIntensity, fadeMs?: number) => Promise<void>
stopMusic: (fadeMs?: number) => Promise<void>
playPhaseSound: (sound: PhaseSound) => Promise<void>
playCountdown: (seconds: number) => Promise<void> // 3, 2, 1
unloadAll: () => Promise<void>
}
```
---
## Implémentation — useAudioEngine
### Configuration de la session audio iOS/Android
**Critique à faire en premier, avant tout chargement de son.**
```typescript
import { Audio } from 'expo-av'
// À appeler une seule fois au démarrage de l'app (dans _layout.tsx)
export async function configureAudioSession(): Promise<void> {
await Audio.setAudioModeAsync({
// iOS : jouer par-dessus la musique de l'utilisateur
playsInSilentModeIOS: true, // ignore le switch mute (si overrideMuteSwitch)
allowsRecordingIOS: false,
staysActiveInBackground: true, // CRITIQUE — timer continue en background
// Android
shouldDuckAndroid: true, // baisser le volume autres apps sur nos signaux
playThroughEarpieceAndroid: false,
interruptionModeIOS: 1, // DO_NOT_MIX = 1 (nos sons ont priorité sur signaux)
interruptionModeAndroid: 1,
})
}
```
### Preloading — charger AVANT la séance
Tous les sons doivent être chargés en mémoire AVANT que la séance commence.
Si on charge pendant la séance → latence inacceptable.
```typescript
const soundRefs = useRef<Record<string, Audio.Sound>>({})
async function preloadAll(settings: AudioSettings): Promise<void> {
// 1. Charger la track musicale correspondante à l'ambiance + les 2 intensités
const trackWork = getTrack(settings.ambiance, 'HIGH')
const trackRest = getTrack(settings.ambiance, 'LOW')
soundRefs.current['music_work'] = await loadSound(trackWork.asset)
soundRefs.current['music_rest'] = await loadSound(trackRest.asset)
// 2. Charger tous les signaux de phase
const soundAssets = getSoundAssets(settings)
for (const [key, asset] of Object.entries(soundAssets)) {
soundRefs.current[key] = await loadSound(asset)
}
// 3. Mettre en loop les tracks musicales
await soundRefs.current['music_work'].setIsLoopingAsync(true)
await soundRefs.current['music_rest'].setIsLoopingAsync(true)
}
async function loadSound(asset: number): Promise<Audio.Sound> {
const { sound } = await Audio.Sound.createAsync(asset, {
shouldPlay: false,
volume: 1.0,
})
return sound
}
```
### Crossfade entre phases
```typescript
async function switchIntensity(
to: MusicIntensity,
fadeMs: number = 500
): Promise<void> {
const from = currentIntensityRef.current
if (from === to) return
const outKey = from === 'HIGH' ? 'music_work' : 'music_rest'
const inKey = to === 'HIGH' ? 'music_work' : 'music_rest'
const outSound = soundRefs.current[outKey]
const inSound = soundRefs.current[inKey]
if (!outSound || !inSound) return
// Démarrer la track entrante depuis le début (ou depuis la position actuelle ?)
await inSound.setPositionAsync(0)
await inSound.setVolumeAsync(0)
await inSound.playAsync()
// Fade simultané
const steps = 10
const stepMs = fadeMs / steps
const volumeStep = settings.musicVolume / steps
for (let i = 0; i <= steps; i++) {
await Promise.all([
outSound.setVolumeAsync(Math.max(0, settings.musicVolume - i * volumeStep)),
inSound.setVolumeAsync(Math.min(settings.musicVolume, i * volumeStep)),
])
await delay(stepMs)
}
await outSound.stopAsync()
currentIntensityRef.current = to
}
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
```
### Répondre aux événements timer
```typescript
// Dans useTimerSync — pont entre timer et audio
import { useTimerEngine } from '../timer'
import { useAudioEngine } from '../audio'
export function useTimerSync() {
const timer = useTimerEngine()
const audio = useAudioEngine()
useEffect(() => {
const unsubscribe = timer.addEventListener((event) => {
switch (event.type) {
case 'PHASE_CHANGED':
handlePhaseChange(event.from, event.to)
break
case 'COUNTDOWN_TICK':
audio.playCountdown(event.secondsLeft)
break
case 'SESSION_COMPLETE':
audio.playPhaseSound('completion')
audio.stopMusic(1000)
break
}
})
return unsubscribe
}, [])
async function handlePhaseChange(from: TimerPhase, to: TimerPhase) {
switch (to) {
case 'GET_READY':
await audio.startMusic(settings.ambiance, 'MEDIUM')
break
case 'WORK':
await audio.playPhaseSound(settings.workPhaseSound)
await audio.switchIntensity('HIGH', 500)
break
case 'REST':
await audio.playPhaseSound(settings.restPhaseSound)
await audio.switchIntensity('LOW', 500)
break
}
}
}
```
---
## Catalogue des tracks (offline)
```typescript
// src/features/audio/data/tracks.ts
import { MusicAmbiance, MusicIntensity, AudioTrack } from '../types'
export const TRACKS: AudioTrack[] = [
{
id: 'electro_high',
ambiance: 'ELECTRO',
intensity: 'HIGH',
asset: require('../../../assets/audio/music/electro_high.mp3'),
bpm: 140,
durationMs: 180000,
},
{
id: 'electro_low',
ambiance: 'ELECTRO',
intensity: 'LOW',
asset: require('../../../assets/audio/music/electro_low.mp3'),
bpm: 90,
durationMs: 180000,
},
// ... (9 tracks au total)
]
export function getTrack(ambiance: MusicAmbiance, intensity: MusicIntensity): AudioTrack {
const track = TRACKS.find(t => t.ambiance === ambiance && t.intensity === intensity)
if (!track) throw new Error(`Track non trouvée : ${ambiance} ${intensity}`)
return track
}
```
## Catalogue des sons de phase
```typescript
// src/features/audio/data/sounds.ts
export const PHASE_SOUNDS = {
beep: require('../../../assets/audio/sounds/beep_long.mp3'),
whistle: require('../../../assets/audio/sounds/whistle.mp3'),
voice_go: require('../../../assets/audio/sounds/voice_go.mp3'),
air_horn: require('../../../assets/audio/sounds/air_horn.mp3'),
bell: require('../../../assets/audio/sounds/bell.mp3'),
double_beep: require('../../../assets/audio/sounds/beep_double.mp3'),
countdown_3: require('../../../assets/audio/sounds/count_3.mp3'),
countdown_2: require('../../../assets/audio/sounds/count_2.mp3'),
countdown_1: require('../../../assets/audio/sounds/count_1.mp3'),
fanfare: require('../../../assets/audio/sounds/fanfare.mp3'),
clap: require('../../../assets/audio/sounds/applause.mp3'),
}
```
---
## Gestion du switch silencieux iOS
```typescript
// Si overrideMuteSwitch = true dans les settings
await Audio.setAudioModeAsync({
playsInSilentModeIOS: settings.overrideMuteSwitch,
})
```
**Règle UX :** Les signaux de phase ont l'override activé par défaut
(l'utilisateur a besoin de savoir quand changer d'exercice même en mode silencieux).
La musique d'ambiance respecte le switch silencieux par défaut.
---
## Cleanup obligatoire
```typescript
// Dans useAudioEngine — cleanup au unmount ou fin de séance
async function unloadAll(): Promise<void> {
await Promise.all(
Object.values(soundRefs.current).map(sound => sound.unloadAsync())
)
soundRefs.current = {}
}
useEffect(() => {
return () => {
// Cleanup synchrone au unmount
Object.values(soundRefs.current).forEach(sound => {
sound.unloadAsync().catch(console.error)
})
}
}, [])
```
---
## Tests minimaux
```typescript
describe('useAudioEngine', () => {
it('configure la session audio au mount', () => {})
it('preloadAll charge les sons sans erreur', () => {})
it('playPhaseSound joue le bon son dans les 50ms', () => {})
it('switchIntensity effectue un crossfade', () => {})
it('stopMusic fait un fade-out', () => {})
it('unloadAll libère toutes les ressources', () => {})
it('ne plante pas si un asset est manquant (fallback)', () => {})
})
```
---
## Erreurs classiques à éviter
```typescript
// ❌ Charger les sons à la demande → latence
case 'PHASE_CHANGED':
const { sound } = await Audio.Sound.createAsync(asset) // ← trop lent !
await sound.playAsync()
// ✅ Utiliser les sons préchargés
case 'PHASE_CHANGED':
await soundRefs.current['work_start'].replayAsync() // ← instantané
// ❌ Oublier de configurer la session audio → muet sur iOS background
await sound.playAsync() // sans Audio.setAudioModeAsync() → silencieux en background
// ❌ Pas de gestion du duck Android → nos sons se mélangent avec d'autres apps
interruptionModeAndroid: 2 // DUCK_OTHERS — OK pour la musique d'ambiance
interruptionModeAndroid: 1 // DO_NOT_MIX — pour les signaux de phase
```

View File

@@ -1 +0,0 @@
../../.agents/skills/building-native-ui

View File

@@ -1,576 +0,0 @@
---
name: tabata-kine-design-system
description: >
Design system complet pour l'application Tabata Kiné. Utilise ce skill pour
toute tâche liée au design, aux composants UI, aux écrans, aux couleurs, à la
typographie ou aux décisions d'interface de l'app Tabata Kiné. Déclenche ce
skill dès que l'utilisateur mentionne : un écran de l'app (onboarding, séance,
dashboard, paywall, programmes), un composant (bouton, carte, timer, badge,
input), une couleur, une typographie, une animation, un espacement, ou demande
à coder un élément UI. Ce skill contient les règles non négociables du design
"Dark Medical" — le style qui différencie l'app de tous les concurrents fitness.
---
# Design System — Tabata Kiné
## Principe directeur : Dark Medical
L'app Tabata Kiné n'est **pas** une app fitness classique. C'est une app médicale
qui utilise le format tabata. Le style "Dark Medical" traduit visuellement ce
positionnement : fond sombre professionnel, vert santé comme seule couleur d'action,
expertise kiné visible à chaque écran.
**Règles absolues :**
- Pas de mode clair. Dark only, sans exception.
- Le vert (#00C896) ne sert qu'aux actions et à la validation.
- L'orange (#FF8A5C) ne sert qu'aux conseils kiné et alertes positives.
- Le rouge (#FF4444) est réservé au timer en phase d'urgence (<10s).
- Touch target minimum : 44×44px pour tous les éléments interactifs.
---
## 1. Tokens de couleur
### Fonds — Navy
| Token | Valeur | Usage |
|-------|--------|-------|
| `navy-900` | `#0D1B2A` | Fond principal de l'app |
| `navy-800` | `#112240` | Surface 1 — cartes par défaut |
| `navy-700` | `#1A3050` | Surface 2 — cartes surélevées |
| `navy-600` | `#243C5E` | Bordures actives |
### Vert Kiné — action & santé
| Token | Valeur | Usage |
|-------|--------|-------|
| `green-500` | `#00C896` | CTA principal, timer effort, progress |
| `green-600` | `#00A67C` | État hover / pressed |
| `green-700` | `#00875F` | État active deep |
| `green-dim` | `rgba(0,200,150,0.12)` | Fond badge, chip, card accent |
| `green-border` | `rgba(0,200,150,0.35)` | Bordure card accent |
### Texte & bordures
| Token | Valeur | Usage |
|-------|--------|-------|
| `white-100` | `#E6F1FF` | Texte primaire |
| `slate-300` | `#A8B2D8` | Texte secondaire |
| `slate-400` | `#8892B0` | Texte tertiaire, placeholders |
| `border-dim` | `rgba(168,178,216,0.15)` | Bordure par défaut |
| `border-hover` | `rgba(168,178,216,0.25)` | Bordure hover |
### Orange — conseils kiné uniquement
| Token | Valeur | Usage |
|-------|--------|-------|
| `orange-500` | `#FF8A5C` | Tip card border, badge Kiné+ |
| `orange-600` | `#E06A3C` | Hover orange |
| `orange-dim` | `rgba(255,138,92,0.12)` | Fond tip card |
### Sémantique
| Token | Valeur | Usage |
|-------|--------|-------|
| `red-500` | `#FF4444` | Timer urgence <10s UNIQUEMENT |
---
## 2. Typographie
### Familles
| Rôle | Famille | Notes |
|------|---------|-------|
| Titres émotionnels | Serif italique (ex: DM Serif Display, Georgia) | Célébration, fin de séance, accroches |
| Interface & corps | Sans-serif géométrique (ex: Outfit, DM Sans) | Navigation, descriptions, labels |
| Données & timer | Monospace (ex: DM Mono, JetBrains Mono) | Timer, stats, codes, metadata |
### Échelle
| Style | Famille | Taille | Poids | Usage |
|-------|---------|--------|-------|-------|
| `display` | Serif italic | 2832px | 400 | Fin de séance, titres forts |
| `heading-1` | Serif | 2224px | 500 | Titres de section |
| `heading-2` | Sans | 18px | 500 | Titre exercice, carte programme |
| `body` | Sans | 1516px | 400 | Corps, conseil kiné |
| `label` | Mono | 1113px | 500 | Tags, metadata, uppercase tracking |
| `timer` | Mono | **80100px** | 500 | Timer séance — lisible à 2 mètres |
| `caption` | Sans | 12px | 400 | Sous-labels, hints |
**Règle typographie :** La taille du timer est la décision de design la plus
importante de l'écran séance. Tout se dimensionne autour de lui.
---
## 3. Espacement
Base : **4px**
| Token | Valeur | Usage |
|-------|--------|-------|
| `space-1` | 4px | Gap minimal entre éléments liés |
| `space-2` | 8px | Gap interne composant |
| `space-3` | 12px | Gap entre composants proches |
| `space-4` | 16px | Padding carte, gap standard |
| `space-6` | 24px | Espacement sections |
| `space-8` | 32px | Padding écran horizontal |
| `space-12` | 48px | Espacement majeur |
| `space-16` | 64px | Espacement entre blocs screens |
---
## 4. Border Radius
| Token | Valeur | Usage |
|-------|--------|-------|
| `radius-sm` | 4px | Badge, chip, tag |
| `radius-md` | 8px | Bouton, input, tip card |
| `radius-lg` | 12px | Carte programme standard |
| `radius-xl` | 16px | Carte large, modal |
| `radius-pill` | 9999px | Pill, toggle, progress bar |
| `radius-circle` | 50% | Icon button, avatar, streak dot |
---
## 5. Système d'élévation (surfaces)
```
Fond (navy-900)
└── Surface 1 (navy-800) — cartes par défaut
└── Surface 2 (navy-700) — cartes surélevées / hover
└── Surface active (navy-800 + border green-500 1.5px)
```
Différencier les surfaces **uniquement par la couleur de fond**, jamais par des
ombres portées (box-shadow : non). La bordure active verte est le seul signal
d'état sélectionné.
---
## 6. Composants
### Boutons
```
PrimaryButton
background: green-500
color: navy-900
padding: 14px 24px
height: 5256px
border-radius: radius-md
font: sans 15px 500
width: 100% (full-width dans les screens)
hover: background green-600
active: background green-700 + scale(0.98)
SecondaryButton
background: transparent
color: green-500
border: 1.5px solid green-500
padding: 13px 24px
hover: background green-dim
GhostButton
background: transparent
color: slate-300
no border
usage: actions secondaires (Passer, Annuler)
DangerButton
background: rgba(255,68,68,0.12)
color: #FF6B6B
border: 1px solid rgba(255,68,68,0.3)
usage: Quitter la séance UNIQUEMENT
IconButton
width: 44px
height: 44px
border-radius: 50%
background: rgba(168,178,216,0.10)
color: slate-300
JAMAIS en dessous de 44×44px (accessibilité)
```
### Inputs
```
TextField
background: navy-800
border: 1px solid border-dim
border-radius: radius-md
padding: 12px 16px
color: white-100
font: sans 15px 400
focus: border green-500
error: border red-500
height: 48px
```
### Badges & Pills
```
Badge (tier)
font: mono 11px 500
padding: 3px 10px
border-radius: radius-sm
UPPERCASE + letter-spacing: 0.08em
.free: background green-dim, color green-500
.premium: background orange-dim, color orange-500
.kine: background rgba(168,178,216,0.12), color slate-300
Pill (metadata)
font: sans 12px 400
padding: 4px 12px
border-radius: radius-pill
border: 1px solid (couleur correspondante à 0.3 opacity)
```
### Cartes
```
CardDefault
background: navy-800
border: 1px solid border-dim
border-radius: radius-lg
padding: 16px
CardAccent (CTA, prochaine séance)
background: rgba(0,200,150,0.05)
border: 1.5px solid green-border
border-radius: radius-lg
CardTip (conseil kiné)
background: orange-dim
border-left: 3px solid orange-500
border-radius: 0 radius-lg radius-lg 0
NE PAS arrondir le côté gauche (border-left unique)
Structure: icône 💡 + texte + signature "— Prénom, kiné"
CardProgram
border-radius: radius-xl
overflow: hidden
Thumbnail: 120px height, gradient navy-700→navy-600
Body: padding 14px
Toujours afficher: progression bar + "X/12 séances"
```
### Timer
```
Timer (composant le plus critique de l'app)
font: mono 80100px 500
text-align: center
État effort normal (>10s):
color: green-500
État urgence (<10s):
color: red-500
animation: pulse subtil (scale 1→1.02→1, 1s infinite)
Label sous le chiffre:
font: mono 14px 400
color: slate-400
letter-spacing: 0.1em
text: "SECONDES"
Contexte repos:
color: slate-300 (pas de vert, signal visuel de repos)
```
### Progress Bar
```
ProgressBar
track: background rgba(168,178,216,0.12), height 4px, border-radius pill
fill: background green-500, border-radius pill
Variante séance (épaisseur réduite):
height: 3px
Variante programme:
height: 4px
Afficher le % à droite en mono 11px green-500
Animation: transition width 300ms ease
```
### Feedback ressenti
```
FeedbackButton
width: flex (3 boutons égaux)
height: 72px
border-radius: radius-lg
background: navy-800
border: 1px solid border-dim
flex-direction: column
gap: 4px
Emoji: 28px
Label: sans 12px slate-400
État sélectionné:
border: 1.5px solid green-500
background: green-dim
```
### Streak hebdomadaire
```
StreakDot
width: 32px
height: 32px
border-radius: 50%
.done: background rgba(0,200,150,0.15) → afficher ✓
.today: background green-500 → afficher ✓
.empty: background rgba(168,178,216,0.06), border 1px border-dim
Label jour: mono 10px slate-400, centré sous chaque dot
```
---
## 7. Écran séance — règles spéciales
L'écran séance est le plus critique de l'app. Il doit être utilisable **les mains
sur les genoux, en sueur, à 2 mètres de l'écran**. Chaque décision de design doit
passer ce test.
### Architecture visuelle
```
[Vidéo plein écran en boucle — fond de tout l'écran]
↓ Gradient top navy→transparent (40% opacité, 100px height)
→ Contrôles pause/audio en overlay
→ Indicateur exercice X/8 centré
↓ Zone centrale nette (pas de gradient — l'utilisateur voit le mouvement)
↓ Gradient bottom transparent→navy (70% opacité, 220px height)
→ Timer géant centré
→ Progress bar
→ Tip card conseil kiné
```
### Transitions séance
```
Effort → Repos:
Fond passe de vidéo plein écran → navy-800 uni
Transition: fade 300ms
Vibration haptique légère (si disponible)
Le repos a une identité visuelle différente (pas de vidéo, couleur unie)
Repos → Effort:
Countdown audio "3... 2... 1..."
Vibration haptique + transition fade
Exercice suivant pendant le repos:
Afficher un thumbnail 56×56px du prochain exercice
Nom en sans 14px 500
Label "PROCHAIN EXERCICE" en mono 11px slate-400
```
### Phase repos
L'écran repos doit être **visuellement différent** de l'écran effort.
- Fond : `navy-800` uni (plus de vidéo plein écran)
- Timer couleur : `slate-300` (pas de vert — c'est le repos)
- Mot "REPOS" en mono 13px slate-400, letter-spacing 0.15em
- Aperçu prochain exercice centré
---
## 8. Navigation
```
Tab Bar (5 onglets, fixé en bas)
height: 56px + safe area inset
background: navy-800
border-top: 1px solid border-dim
Onglets:
- Accueil (home icon)
- Programmes (grid icon)
- Minuteur (timer icon)
- Progression (chart icon)
- Profil (person icon)
Onglet actif: icône green-500 + label green-500
Onglet inactif: icône slate-400 + label slate-400
Font label: sans 11px 400
Icon size: 22×22px
Touch target: 44×44px minimum
```
---
## 9. Animations & micro-interactions
```
FadeIn:
opacity: 0 → 1
duration: 300ms
easing: ease
SlideUp (bottom sheet, modal):
translateY(100%) → 0
duration: 400ms
easing: cubic-bezier(0.4, 0, 0.2, 1)
Pulse (CTA bouton, timer urgence):
scale: 1 → 1.02 → 1
duration: 2s
infinite, ease-in-out
Bounce (célébration fin de séance):
scale: 0.5 → 1.05 → 0.98 → 1
duration: 600ms
easing: spring
StaggerList (items qui apparaissent en séquence):
Délai: 100ms entre chaque item
Chaque item: FadeIn + translateY(12px→0)
ScalePress (tous les boutons):
active: scale(0.97)
duration: 100ms
```
**Règle d'or animations :** Une animation bien exécutée au chargement d'écran
vaut mieux que des micro-interactions dispersées partout.
---
## 10. Paywall — règles de design conversion
```
Structure obligatoire du paywall:
1. Célébration des accomplissements (TOUJOURS en premier)
→ Font serif italic, emoji, stats concrètes
2. Valeur du contenu débloqué (liste concrète)
3. Pricing transparent (pas de dark patterns)
→ "Essai gratuit 7 jours · puis 24,99€/an · soit 2,08€/mois"
4. CTA principal (PrimaryButton full-width)
5. Réassurance ("Annulation facile à tout moment")
6. Alternative gratuite visible (GhostButton ou lien)
Couleur encadré pricing: CardAccent (vert)
Bouton fermeture: TOUJOURS visible en haut à gauche
Pas de compte à rebours fictif, pas de stock limité : anti dark patterns
```
---
## 11. Accessibilité
```
Contraste texte:
Texte primaire (#E6F1FF) sur navy-900 → ratio 15:1 ✓
Texte secondaire (#A8B2D8) sur navy-900 → ratio 7:1 ✓
Vert (#00C896) sur navy-900 → ratio 8:1 ✓
Tous conformes WCAG AA (4.5:1 minimum requis)
Touch targets:
Minimum 44×44px pour TOUS les éléments interactifs
Espacement minimum 8px entre deux éléments interactifs adjacents
Timer:
La couleur n'est pas le seul signal d'urgence
Ajouter aussi: pulse animation + vibration haptique + signal audio
Audio:
Toujours proposer une alternative visuelle à chaque signal audio
Le toggle audio est accessible en 1 tap depuis l'écran séance
```
---
## 12. Tokens React Native / Expo
```typescript
// design-tokens.ts
export const colors = {
// Navy
navy900: '#0D1B2A',
navy800: '#112240',
navy700: '#1A3050',
navy600: '#243C5E',
// Green
green500: '#00C896',
green600: '#00A67C',
green700: '#00875F',
greenDim: 'rgba(0,200,150,0.12)',
greenBorder: 'rgba(0,200,150,0.35)',
// Text
white100: '#E6F1FF',
slate300: '#A8B2D8',
slate400: '#8892B0',
// Borders
borderDim: 'rgba(168,178,216,0.15)',
borderHover: 'rgba(168,178,216,0.25)',
// Orange (tip/kine only)
orange500: '#FF8A5C',
orange600: '#E06A3C',
orangeDim: 'rgba(255,138,92,0.12)',
// Semantic
red500: '#FF4444', // timer urgence ONLY
} as const
export const spacing = {
1: 4,
2: 8,
3: 12,
4: 16,
6: 24,
8: 32,
12: 48,
16: 64,
} as const
export const radius = {
sm: 4,
md: 8,
lg: 12,
xl: 16,
pill: 9999,
} as const
export const fontSizes = {
caption: 12,
label: 13,
body: 15,
heading2: 18,
heading1: 22,
display: 28,
timer: 88, // taille par défaut du timer
} as const
export const timerThreshold = 10 // secondes — passage vert → rouge
```
---
## Checklist avant livraison d'un écran
- [ ] Fond `navy-900` utilisé comme base
- [ ] Aucun shadow/élévation — différenciation par couleur uniquement
- [ ] Tous les touch targets ≥ 44×44px
- [ ] Le vert n'est utilisé que pour des actions ou validations
- [ ] L'orange n'est utilisé que pour des conseils kiné ou alertes positives
- [ ] Le rouge n'apparaît que sur le timer en urgence
- [ ] Timer ≥ 80px de haut sur l'écran séance
- [ ] Vidéo plein écran sur l'écran séance (pas un bloc vidéo)
- [ ] Gradients top + bottom sur l'écran séance pour la lisibilité
- [ ] Phase repos visuellement différente de la phase effort
- [ ] Paywall : célébration en premier, alternative gratuite visible
- [ ] Typographie : serif pour les moments émotionnels, mono pour les données

View File

@@ -1,428 +0,0 @@
# Skill — Exercices (useExercise + ExerciseDisplay)
> Lis ce skill AVANT d'implémenter quoi que ce soit lié aux exercices.
## Responsabilité de ce module
Gérer la bibliothèque d'exercices, la sélection selon le programme,
et l'affichage contextuel selon la phase du timer.
**Offline-first** : toutes les données et les GIFs sont dans le bundle.
---
## Architecture du module exercices
```
src/features/exercises/
types.ts
hooks/
useExercise.ts ← sélection + navigation entre exercices
useExerciseLibrary.ts ← recherche, filtrage, favoris
components/
ExerciseDisplay.tsx ← affichage contextuel selon la phase timer
ExerciseGif.tsx ← Image GIF avec fallback + accessibilité
ExerciseCard.tsx ← carte pour la bibliothèque/programme
ExerciseCues.tsx ← affichage des cues de forme
data/
exercises.ts ← les 38 exercices (source de vérité)
categories.ts ← définition des catégories
index.ts
```
---
## Types
```typescript
// src/features/exercises/types.ts
export type ExerciseCategory =
| 'CARDIO'
| 'LOWER_BODY'
| 'UPPER_BODY'
| 'CORE'
| 'LOW_IMPACT'
| 'EQUIPMENT'
export type ExerciseDifficulty = 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'
export interface Exercise {
id: string
name: Record<string, string> // clés = codes langue : { fr, en, es, de, pt }
category: ExerciseCategory
difficulty: ExerciseDifficulty
musclesTargeted: string[]
description: Record<string, string> // max 80 caractères par langue
cues: Record<string, string[]> // 2-3 points clés de forme par langue
gifAsset: number | null // require(...) ou null si pas de GIF
thumbnailAsset: number | null
hasModification: boolean // variante plus facile disponible
modificationId: string | null // id de l'exercice modifié
equipmentNeeded: string[] // [] = aucun matériel
estimatedCaloriesPerMinute: number // approximatif
}
export type ProgramMode = 'SINGLE' | 'DUO' | 'CIRCUIT_4' | 'FREE'
export interface ExerciseProgram {
id: string
name: Record<string, string>
description: Record<string, string>
mode: ProgramMode
// Pour SINGLE : [exerciseId] — même exercice répété
// Pour DUO : [exerciseIdA, exerciseIdB] — alternance
// Pour CIRCUIT_4 : [a, b, c, d] — 4 exercices × 2 rounds
// Pour FREE : exerciseId par round [r1, r2, r3, r4, r5, r6, r7, r8]
exerciseIds: string[]
difficulty: ExerciseDifficulty
totalRounds: number
isDefault: boolean // programmes pré-définis vs créés par user
}
// Résout l'exercice pour un round donné selon le mode du programme
export interface ExerciseState {
current: Exercise
next: Exercise | null // null sur le dernier round
roundIndex: number // 0-indexé
}
```
---
## Données — les 38 exercices V1
Structure à respecter pour chaque exercice :
```typescript
// src/features/exercises/data/exercises.ts
import { Exercise } from '../types'
export const EXERCISES: Exercise[] = [
{
id: 'burpee_classic',
name: { fr: 'Burpee', en: 'Burpee', es: 'Burpee', de: 'Burpee', pt: 'Burpee' },
category: 'CARDIO',
difficulty: 'INTERMEDIATE',
musclesTargeted: ['quadriceps', 'pectoraux', 'épaules', 'cardio'],
description: {
fr: 'Position debout → squat → planche → pompe → saut vertical',
en: 'Stand → squat → plank → push-up → jump',
es: 'De pie → sentadilla → plancha → flexión → salto',
de: 'Stehen → Hocke → Plank → Liegestütz → Sprung',
pt: 'Em pé → agachamento → prancha → flexão → salto',
},
cues: {
fr: ['Atterris doucement sur les orteils', 'Garde le dos droit en planche', 'Explose vers le haut'],
en: ['Land softly on toes', 'Keep back flat in plank', 'Explode upward'],
es: ['Aterriza suavemente', 'Espalda recta en plancha', 'Explota hacia arriba'],
de: ['Sanft auf den Zehen landen', 'Rücken gerade halten', 'Nach oben explodieren'],
pt: ['Aterrissar suavemente', 'Costas retas na prancha', 'Explodir para cima'],
},
gifAsset: require('../../../assets/exercises/burpee_classic.gif'),
thumbnailAsset: require('../../../assets/exercises/thumbs/burpee_classic.jpg'),
hasModification: true,
modificationId: 'burpee_modified',
equipmentNeeded: [],
estimatedCaloriesPerMinute: 12,
},
// ... 37 autres exercices
]
// Accès rapide par ID
export const EXERCISES_MAP = Object.fromEntries(
EXERCISES.map(ex => [ex.id, ex])
) as Record<string, Exercise>
export function getExerciseById(id: string): Exercise {
const ex = EXERCISES_MAP[id]
if (!ex) throw new Error(`Exercice non trouvé : ${id}`)
return ex
}
```
**Liste des 38 exercices V1 à implémenter :**
```
CARDIO (8) : burpee_classic, burpee_modified, jumping_jacks,
mountain_climbers, high_knees, jump_rope_sim,
box_jump_sim, lateral_shuffles
LOWER_BODY (8) : squat_classic, squat_jump, lunge_alternating,
lunge_jump, glute_bridge, wall_sit, calf_raises,
sumo_squat
UPPER_BODY (6) : pushup_classic, pushup_modified, pike_pushup,
tricep_dip, shoulder_taps, inchworm
CORE (6) : crunch_classic, plank_hold, russian_twist,
bicycle_crunch, leg_raises, dead_bug
LOW_IMPACT (6) : march_in_place, step_touch, modified_squat,
standing_oblique, slow_pushup, chair_stand
EQUIPMENT (4) : kb_swing, db_thruster, resistance_band_row,
medicine_ball_slam
```
---
## Hook useExercise
```typescript
// src/features/exercises/hooks/useExercise.ts
export function useExercise(program: ExerciseProgram): {
getExerciseForRound: (round: number) => ExerciseState
currentExercise: Exercise
nextExercise: Exercise | null
} {
function getExerciseForRound(round: number): ExerciseState {
// round est 1-indexé
const index = round - 1
let currentId: string
let nextId: string | null
switch (program.mode) {
case 'SINGLE':
currentId = program.exerciseIds[0]
nextId = null // même exercice → pas de "suivant" différent
break
case 'DUO':
currentId = program.exerciseIds[index % 2]
nextId = program.exerciseIds[(index + 1) % 2]
break
case 'CIRCUIT_4':
currentId = program.exerciseIds[index % 4]
nextId = program.exerciseIds[(index + 1) % 4]
break
case 'FREE':
currentId = program.exerciseIds[index] ?? program.exerciseIds[0]
nextId = program.exerciseIds[index + 1] ?? null
break
}
return {
current: getExerciseById(currentId),
next: nextId ? getExerciseById(nextId) : null,
roundIndex: index,
}
}
return { getExerciseForRound, /* ... */ }
}
```
---
## Composant ExerciseDisplay — affichage contextuel
Le composant adapte son layout selon la phase du timer.
```typescript
interface ExerciseDisplayProps {
phase: TimerPhase
exercise: Exercise
nextExercise: Exercise | null
lang: string // code langue actuel
}
export function ExerciseDisplay({ phase, exercise, nextExercise, lang }: ExerciseDisplayProps) {
switch (phase) {
case 'GET_READY':
return <GetReadyView exercise={exercise} lang={lang} />
// ↑ GIF grand format + nom + "Prépare-toi !"
case 'WORK':
return <WorkView exercise={exercise} lang={lang} />
// ↑ Nom en haut + 2 cues + GIF petit coin bas-droit
case 'REST':
return <RestView exercise={exercise} nextExercise={nextExercise} lang={lang} />
// ↑ "Repos" + si nextExercise : "Prochain : [nom]" + vignette
default:
return null
}
}
```
### GetReadyView
```
┌─────────────────────────────┐
│ │
│ ┌─────────────────┐ │
│ │ [GIF 200x200] │ │ ← Grand GIF centré
│ └─────────────────┘ │
│ │
│ Burpees │ ← Nom, police bold 28px
│ ← Dos droit en planche │ ← Cue #1
│ ← Explose vers le haut │ ← Cue #2
│ │
└─────────────────────────────┘
```
### WorkView
```
┌─────────────────────────────┐
│ Burpees │ ← Nom, 22px, coin haut-gauche
│ ← Dos droit │ ← Cue #1, 16px
│ ← Explose vers le haut │ ← Cue #2, 16px
│ │
│ [Timer central] │
│ │
│ ┌────┐ │
│ │GIF │ │ ← GIF 80x80, coin bas-droit
│ └────┘ │
└─────────────────────────────┘
```
### RestView
```
┌─────────────────────────────┐
│ │
│ REPOS │ ← "REPOS" centré, grand
│ │
│ Prochain : Mountain │ ← Si nextExercise != null
│ Climbers │
│ ┌──────┐ │
│ │ GIF │ │ ← Vignette 60x60 de nextExercise
│ └──────┘ │
└─────────────────────────────┘
```
---
## Composant ExerciseGif — robuste et accessible
```typescript
interface ExerciseGifProps {
exercise: Exercise
size: 'small' | 'medium' | 'large'
lang: string
}
const GIF_SIZES = { small: 80, medium: 120, large: 200 }
export function ExerciseGif({ exercise, size, lang }: ExerciseGifProps) {
const [hasError, setHasError] = useState(false)
const dimension = GIF_SIZES[size]
if (!exercise.gifAsset || hasError) {
// Fallback : icône + initiales de l'exercice
return (
<View
style={[styles.fallback, { width: dimension, height: dimension }]}
accessible={true}
accessibilityLabel={exercise.name[lang] ?? exercise.name['en']}
>
<Text style={styles.fallbackIcon}>💪</Text>
<Text style={styles.fallbackText}>{getInitials(exercise.name[lang])}</Text>
</View>
)
}
return (
<Image
source={exercise.gifAsset}
style={{ width: dimension, height: dimension }}
onError={() => setHasError(true)}
accessible={true}
accessibilityLabel={`Démonstration de ${exercise.name[lang]}`}
/>
)
}
```
---
## Localisation des exercices
```typescript
// Utilitaire pour extraire la bonne langue avec fallback
export function getLocalizedText(
record: Record<string, string>,
lang: string
): string {
return record[lang] ?? record['en'] ?? Object.values(record)[0] ?? ''
}
// Utilisation
const name = getLocalizedText(exercise.name, userLang)
const cues = (exercise.cues[userLang] ?? exercise.cues['en'] ?? [])
```
---
## Programmes pré-définis V1
```typescript
// src/features/exercises/data/defaultPrograms.ts
export const DEFAULT_PROGRAMS: ExerciseProgram[] = [
{
id: 'beginner_classic',
name: { fr: 'Débutant Classic', en: 'Beginner Classic' },
description: { fr: 'Parfait pour commencer le Tabata', en: 'Perfect to start Tabata' },
mode: 'SINGLE',
exerciseIds: ['squat_classic'],
difficulty: 'BEGINNER',
totalRounds: 8,
isDefault: true,
},
{
id: 'full_body_duo',
name: { fr: 'Full Body Duo', en: 'Full Body Duo' },
description: { fr: 'Squats + Pompes — le duo parfait', en: 'Squats + Push-ups — the perfect duo' },
mode: 'DUO',
exerciseIds: ['squat_jump', 'pushup_classic'],
difficulty: 'INTERMEDIATE',
totalRounds: 8,
isDefault: true,
},
{
id: 'cardio_blast',
name: { fr: 'Cardio Blast', en: 'Cardio Blast' },
description: { fr: 'Circuit intense 4 exercices cardio', en: '4-exercise intense cardio circuit' },
mode: 'CIRCUIT_4',
exerciseIds: ['burpee_classic', 'high_knees', 'mountain_climbers', 'jumping_jacks'],
difficulty: 'ADVANCED',
totalRounds: 8,
isDefault: true,
},
// + programmes par objectif (perte de poids, force, low impact, etc.)
]
```
---
## Tests minimaux
```typescript
describe('useExercise', () => {
it('MODE SINGLE : retourne le même exercice à chaque round', () => {})
it('MODE DUO : alterne A/B correctement sur 8 rounds', () => {})
it('MODE CIRCUIT_4 : cycle sur 4 exercices', () => {})
it('retourne null pour nextExercise sur le dernier round en FREE', () => {})
it('getExerciseById lance une erreur si id inconnu', () => {})
})
describe('ExerciseDisplay', () => {
it('affiche le GIF grand format en GET_READY', () => {})
it('affiche les cues en WORK', () => {})
it('affiche nextExercise en REST si disponible', () => {})
it('affiche le fallback si gifAsset est null', () => {})
it('est accessible (accessibilityLabel présent)', () => {})
})
```
---
## Checklist assets exercices
Avant de lancer en production, vérifier :
- [ ] 38 GIFs présents dans `assets/exercises/` (200×200px, loop, < 200KB chacun)
- [ ] 38 thumbnails présents dans `assets/exercises/thumbs/` (< 30KB chacun)
- [ ] Toutes les traductions présentes pour EN, FR, ES, DE, PT
- [ ] Tous les `modificationId` pointent vers un exercice existant
- [ ] Bundle total des assets exercices < 10MB

View File

@@ -21,8 +21,9 @@ Run from the project root. This parses all source files, builds the knowledge gr
| -------------- | ---------------------------------------------------------------- |
| `--force` | Force full re-index even if up to date |
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
### status — Check index freshness

View File

@@ -1,349 +0,0 @@
# Skill — Timer Engine (useTimerEngine)
> Lis ce skill AVANT d'implémenter quoi que ce soit lié au timer.
## Responsabilité de ce module
Le timer est le cœur battant de l'app. Il doit être :
- **Précis** : drift < 50ms sur une séance complète (8 rounds)
- **Résilient** : continue en background, survit aux appels téléphoniques
- **Découplé** : aucune dépendance vers l'audio ou les exercices
- **Testable** : logique pure, facile à tester sans simulateur
---
## Architecture du module timer
```
src/features/timer/
types.ts ← Commencer ICI
hooks/
useTimerEngine.ts ← Moteur central (logique pure)
useTimerSync.ts ← Pont vers audio + exercices
useTimerPersistence.ts ← Sauvegarde config dans AsyncStorage
components/
TimerDisplay.tsx ← Écran plein écran (props-only)
TimerControls.tsx ← Boutons start/pause/stop/skip
TimerPhaseIndicator.tsx ← Barre de progression des rounds
TimerRing.tsx ← Anneau circulaire animé
index.ts ← Barrel export
```
---
## Types — commencer par ici
```typescript
// src/features/timer/types.ts
export type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
export interface TimerConfig {
workDuration: number // défaut : 20s
restDuration: number // défaut : 10s
rounds: number // défaut : 8
getReadyDuration: number // défaut : 10s
cycles: number // défaut : 1 (Premium : jusqu'à 10)
cyclePauseDuration: number // défaut : 60s (pause entre cycles, Premium)
}
export interface TimerState {
phase: TimerPhase
secondsLeft: number
currentRound: number // 1-indexé
totalRounds: number
currentCycle: number // 1-indexé
totalCycles: number
isRunning: boolean
isPaused: boolean
totalElapsedSeconds: number // pour le résumé de séance
}
export interface TimerActions {
start: (config: TimerConfig) => void
pause: () => void
resume: () => void
stop: () => void
skip: () => void // passer à la phase suivante immédiatement
}
// Événements émis par le timer — consommés par useTimerSync
export type TimerEvent =
| { type: 'PHASE_CHANGED'; from: TimerPhase; to: TimerPhase }
| { type: 'ROUND_COMPLETED'; round: number }
| { type: 'SESSION_COMPLETE'; totalSeconds: number }
| { type: 'COUNTDOWN_TICK'; secondsLeft: number } // pour les dernières 3s
export type TimerEventListener = (event: TimerEvent) => void
```
---
## Implémentation — useTimerEngine
### Principe de précision : Date.now() delta
**JAMAIS** se fier uniquement à setInterval pour le compte à rebours.
setInterval drift de 10-100ms par tick sur mobile. Sur 8 rounds
de 20s + 10s, ça peut représenter plusieurs secondes d'écart.
```typescript
// ✅ Approche correcte — delta sur Date.now()
const tickRef = useRef<number | null>(null)
const targetEndTimeRef = useRef<number>(0)
function tick() {
const now = Date.now()
const remaining = Math.max(0, targetEndTimeRef.current - now)
const secondsLeft = Math.ceil(remaining / 1000)
setSecondsLeft(secondsLeft)
if (remaining <= 0) {
advancePhase()
} else {
// Planifier le prochain tick dans ~100ms (pas 1000ms)
// pour une réactivité maximale sur les transitions
tickRef.current = setTimeout(tick, 100)
}
}
function startPhase(duration: number) {
targetEndTimeRef.current = Date.now() + duration * 1000
tickRef.current = setTimeout(tick, 100)
}
```
### Gestion du background (AppState)
```typescript
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextState) => {
if (nextState === 'background' || nextState === 'inactive') {
// Sauvegarder le timestamp cible — pas l'état courant
// Quand on revient en foreground, on recalcule
saveBackgroundTimestamp(targetEndTimeRef.current)
}
if (nextState === 'active') {
const savedTarget = loadBackgroundTimestamp()
if (savedTarget && isRunningRef.current) {
// Recalculer le temps restant depuis le timestamp sauvegardé
targetEndTimeRef.current = savedTarget
// Si le temps est dépassé, avancer aux phases manquées
reconcilePhaseAfterBackground(savedTarget, Date.now())
}
}
})
return () => subscription.remove()
}, [])
```
### Notification sticky pendant la séance
Utiliser expo-notifications pour afficher le décompte en background :
```typescript
async function updateBackgroundNotification(phase: TimerPhase, secondsLeft: number) {
await Notifications.scheduleNotificationAsync({
identifier: 'tabata-timer-sticky',
content: {
title: phase === 'WORK' ? '🔥 Travail !' : '💨 Repos',
body: `${secondsLeft}s — Round ${currentRound}/${totalRounds}`,
sticky: true,
autoDismiss: false,
},
trigger: null,
})
}
```
### Ordre des transitions de phase
```
IDLE
↓ start()
GET_READY (getReadyDuration)
↓ temps écoulé
WORK (workDuration)
↓ temps écoulé
REST (restDuration)
↓ temps écoulé
→ si currentRound < totalRounds : retour à WORK
→ si currentRound === totalRounds ET currentCycle < totalCycles :
PAUSE_BETWEEN_CYCLES → WORK (round 1 du cycle suivant)
→ si currentRound === totalRounds ET currentCycle === totalCycles :
COMPLETE
↓ stop() ou auto-reset après 3s
IDLE
```
### Événements à émettre aux abonnés
```typescript
// À chaque changement de phase
emitEvent({ type: 'PHASE_CHANGED', from: prevPhase, to: nextPhase })
// À chaque fin de round
emitEvent({ type: 'ROUND_COMPLETED', round: currentRound })
// Décompte final (3, 2, 1)
if (secondsLeft <= 3) {
emitEvent({ type: 'COUNTDOWN_TICK', secondsLeft })
}
// Fin de séance
emitEvent({ type: 'SESSION_COMPLETE', totalSeconds: totalElapsedSeconds })
```
---
## Interface publique du hook
```typescript
export function useTimerEngine(): TimerState & TimerActions & {
addEventListener: (listener: TimerEventListener) => () => void
config: TimerConfig
}
```
Le retour doit permettre :
```typescript
const {
phase, secondsLeft, currentRound, totalRounds, isRunning, isPaused,
start, pause, resume, stop, skip,
addEventListener,
config,
} = useTimerEngine()
```
---
## Composant TimerDisplay — layout plein écran
```
┌─────────────────────────────┐ ← StatusBar cachée
│ [Exercice] Round 3/8 ●○○ │ ← Zone haute (20%) — fond semi-transparent
├─────────────────────────────┤
│ │
│ │
│ ┌───────┐ │
│ │ :17 │ │ ← Anneau + chiffre (50% de l'écran)
│ └───────┘ │ ← Police monospace, taille ~120px
│ │
│ ████████████░░░░░░░░░░░░░ │ ← Barre de progression totale (15%)
│ │
│ ⏸ ■ ⏭ │ ← Boutons discrets (15%) — Pause/Stop/Skip
└─────────────────────────────┘
```
Props obligatoires du composant :
```typescript
interface TimerDisplayProps {
state: TimerState
exerciseName: string
nextExerciseName: string
onPause: () => void
onStop: () => void
onSkip: () => void
}
```
### Animations requises
- **Fond** : transition de couleur animée entre phases (600ms ease-in-out)
- `Animated.timing` sur la backgroundColor avec interpolation de couleurs
- **Chiffre** : légère pulsation à chaque seconde (`Animated.sequence` scale 1→1.05→1)
- **Anneau** : `Animated.Value` sur stroke-dashoffset (SVG) ou rotation
- **Décompte final** : flash rouge sur les 3 dernières secondes
```typescript
// Interpolation de couleur entre phases
const backgroundAnim = useRef(new Animated.Value(0)).current
const backgroundColor = backgroundAnim.interpolate({
inputRange: [0, 1, 2, 3],
outputRange: [
PHASE_COLORS.IDLE,
PHASE_COLORS.GET_READY,
PHASE_COLORS.WORK,
PHASE_COLORS.REST,
],
})
// Déclencher quand la phase change
useEffect(() => {
const phaseIndex = { IDLE: 0, GET_READY: 1, WORK: 2, REST: 3, COMPLETE: 0 }
Animated.timing(backgroundAnim, {
toValue: phaseIndex[phase],
duration: 600,
useNativeDriver: false, // obligatoire pour backgroundColor
}).start()
}, [phase])
```
---
## expo-keep-awake — écran allumé pendant la séance
```typescript
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'
// Dans useTimerEngine
useEffect(() => {
if (isRunning) {
activateKeepAwakeAsync('tabata-session')
} else {
deactivateKeepAwake('tabata-session')
}
}, [isRunning])
```
---
## Tests minimaux à écrire
```typescript
describe('useTimerEngine', () => {
it('démarre en phase IDLE avec la config par défaut', () => {})
it('passe à GET_READY quand start() est appelé', () => {})
it('passe à WORK après la fin de GET_READY', () => {})
it('passe à REST après la fin de WORK', () => {})
it('incrémente currentRound après chaque REST', () => {})
it('passe à COMPLETE après le dernier round', () => {})
it('retourne à IDLE après stop()', () => {})
it('skip() avance immédiatement à la phase suivante', () => {})
it('émet PHASE_CHANGED à chaque transition', () => {})
it('émet COUNTDOWN_TICK pour 3, 2, 1', () => {})
it('cleanup useEffect enlève tous les listeners', () => {})
})
```
---
## Erreurs classiques à éviter
```typescript
// ❌ Drift — setInterval seul
setInterval(() => setSecondsLeft(prev => prev - 1), 1000)
// ✅ Delta sur Date.now()
const remaining = Math.max(0, targetEndTimeRef.current - Date.now())
setSecondsLeft(Math.ceil(remaining / 1000))
// ❌ State React pour les valeurs temps-critique
const [targetEndTime, setTargetEndTime] = useState(0)
// ✅ Ref pour les valeurs utilisées dans les closures de timeout
const targetEndTimeRef = useRef(0)
// ❌ Pas de cleanup — memory leak
useEffect(() => {
setTimeout(tick, 100)
})
// ✅ Cleanup systématique
useEffect(() => {
return () => {
if (tickRef.current) clearTimeout(tickRef.current)
}
}, [])
```

View File

@@ -1,233 +0,0 @@
# Skill — Workflow de Développement TabataGo
> Lis ce skill en début de session ou quand tu commences une nouvelle feature.
## Principe fondamental
**Plan d'abord, code ensuite.** Pour toute feature non triviale :
1. Analyse les specs dans le PRD (`docs/PRD.md`)
2. Liste les edge cases et les dépendances
3. Propose l'architecture (types, hooks, composants)
4. Attends validation avant de générer du code
Ne jamais écrire de code avant d'avoir une architecture approuvée
sur les features cœur (timer, audio, exercices).
---
## Cycle de développement par feature
### Étape 1 — Lire les specs
Avant toute implémentation, lis la section PRD correspondante.
Identifie exactement :
- Les entrées/sorties du système
- Les états possibles
- Les cas limites (interruptions, background, erreurs)
### Étape 2 — Typer en premier
Commence TOUJOURS par le fichier `types.ts` de la feature.
Les types sont le contrat — ils précèdent l'implémentation.
```typescript
// Exemple pour le timer — types.ts
export type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
export interface TimerState {
phase: TimerPhase
secondsLeft: number
currentRound: number
totalRounds: number
isRunning: boolean
}
export interface TimerConfig {
workDuration: number // secondes, défaut 20
restDuration: number // secondes, défaut 10
rounds: number // défaut 8
getReadyDuration: number // secondes, défaut 10
}
export interface TimerActions {
start: () => void
pause: () => void
resume: () => void
stop: () => void
skip: () => void
}
```
**Ne passe à l'étape 3 que si les types semblent corrects.**
### Étape 3 — Implémenter le hook (logique métier)
Implémenter le hook central de la feature.
Règles :
- Aucun import depuis React Native UI dans les hooks métier
- Chaque hook expose un état + des actions (pattern Zustand-like)
- Les side effects sont dans useEffect avec cleanup systématique
- Logger les transitions d'état en dev (`__DEV__ && console.log(...)`)
### Étape 4 — Implémenter les composants
Les composants reçoivent tout par props — ils ne fetchent rien,
ne font aucune logique métier.
```typescript
// ✅ Correct
function TimerDisplay({ phase, secondsLeft, currentRound }: TimerDisplayProps) {
return <Text>{secondsLeft}</Text>
}
// ❌ Interdit
function TimerDisplay() {
const timer = useTimerEngine() // logique dans le composant
return <Text>{timer.secondsLeft}</Text>
}
```
### Étape 5 — Tester avant de continuer
Avant de passer à la feature suivante, écrire les tests minimaux :
- État initial correct
- Transitions de phase correctes
- Cleanup des side effects (pas de memory leak)
---
## Patterns obligatoires
### Gestion d'état avec state machine explicite
Toujours utiliser une state machine pour les flows complexes.
Jamais de booléens en cascade (`isRunning && !isPaused && !isComplete`).
```typescript
// ✅ State machine explicite
type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
const VALID_TRANSITIONS: Record<TimerPhase, TimerPhase[]> = {
IDLE: ['GET_READY'],
GET_READY: ['WORK', 'IDLE'],
WORK: ['REST', 'IDLE'],
REST: ['WORK', 'COMPLETE', 'IDLE'],
COMPLETE: ['IDLE'],
}
function canTransition(from: TimerPhase, to: TimerPhase): boolean {
return VALID_TRANSITIONS[from].includes(to)
}
```
### Cleanup systématique des useEffect
```typescript
useEffect(() => {
const subscription = AppState.addEventListener('change', handleAppState)
return () => subscription.remove() // TOUJOURS un cleanup
}, [])
```
### AsyncStorage — wrapper typé uniquement
Ne jamais appeler AsyncStorage directement dans les composants.
Passer par un hook ou un service :
```typescript
// shared/utils/storage.ts
export async function saveTimerConfig(config: TimerConfig): Promise<void> {
await AsyncStorage.setItem('timer_config', JSON.stringify(config))
}
export async function loadTimerConfig(): Promise<TimerConfig | null> {
const raw = await AsyncStorage.getItem('timer_config')
return raw ? JSON.parse(raw) : null
}
```
### Constantes centralisées
```typescript
// shared/constants/timer.ts
export const TIMER_DEFAULTS = {
WORK_DURATION: 20,
REST_DURATION: 10,
ROUNDS: 8,
GET_READY_DURATION: 10,
} as const
// shared/constants/colors.ts
export const PHASE_COLORS = {
GET_READY: '#EAB308', // Jaune
WORK: '#F97316', // Orange
REST: '#3B82F6', // Bleu
COMPLETE: '#22C55E', // Vert
IDLE: '#1E1E2E', // Dark
} as const
```
---
## Gestion des erreurs
### Pattern try/catch dans les hooks async
```typescript
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
async function loadData() {
try {
setIsLoading(true)
setError(null)
const data = await fetchSomething()
setData(data)
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur inconnue')
} finally {
setIsLoading(false)
}
}
```
### Fallback dans les composants
Tout composant qui charge des ressources (GIF, audio) doit avoir
un état de fallback visible et non-bloquant.
---
## Checklist avant de soumettre du code
Avant de considérer une feature terminée, vérifier :
- [ ] Les types sont définis dans `types.ts`
- [ ] La logique est dans des hooks, pas dans des composants
- [ ] Tous les `useEffect` ont un cleanup
- [ ] Aucun `console.log` en dehors de `if (__DEV__)`
- [ ] Les constantes sont dans `shared/constants/`
- [ ] Les fonctions async ont un try/catch
- [ ] Un test minimal existe pour les cas principaux
- [ ] Le barrel export `index.ts` est mis à jour
---
## Workflow de debugging avec les logs Expo
Pour surveiller les logs en temps réel et donner du contexte à Claude :
```bash
# Terminal 1 — démarrer expo avec logs dans un fichier
npx expo start 2>&1 | tee .expo-logs/dev.log
# Ensuite dans Claude Code
> Surveille .expo-logs/dev.log et identifie les warnings
liés au module [nom du module en cours]
```
Ajouter des logs structurés dans les hooks critiques :
```typescript
if (__DEV__) {
console.log('[TimerEngine]', { phase, secondsLeft, currentRound })
}
```
---
## Commandes Claude Code utiles
| Situation | Commande |
|---|---|
| Début de projet / onboarding | `/init` |
| Session longue (>45 min) | `/compact` — focus sur la feature en cours |
| Avant chaque commit | `/review` |
| Quand Claude part en vrille | Donner un fichier précis : `@src/features/timer/hooks/useTimerEngine.ts` |
| Architecture à valider | Demander explicitement "ne génère pas de code, propose seulement l'architecture" |

View File

@@ -2,150 +2,42 @@ name: CI
on:
push:
branches: [main, master]
branches: [main]
pull_request:
branches: [main, master]
branches: [main]
jobs:
typecheck:
name: TypeScript
# ── Path filter — determines which downstream jobs run ──
changes:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
admin-web: ${{ steps.filter.outputs.admin-web }}
youtube-worker: ${{ steps.filter.outputs.youtube-worker }}
supabase-functions: ${{ steps.filter.outputs.supabase-functions }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- uses: dorny/paths-filter@v3
id: filter
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit
lint:
name: ESLint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
test:
name: Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests with coverage
run: npm run test:coverage
- name: Run component render tests
run: npm run test:render
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
retention-days: 7
- name: Coverage summary
if: always()
run: |
echo "## Test Coverage Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ -f coverage/coverage-summary.json ]; then
echo '```' >> $GITHUB_STEP_SUMMARY
node -e "
const c = require('./coverage/coverage-summary.json').total;
const fmt = (v) => v.pct + '%';
console.log('Statements: ' + fmt(c.statements));
console.log('Branches: ' + fmt(c.branches));
console.log('Functions: ' + fmt(c.functions));
console.log('Lines: ' + fmt(c.lines));
" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
elif [ -f coverage/coverage-final.json ]; then
echo "Coverage report generated. Download the artifact for details." >> $GITHUB_STEP_SUMMARY
else
echo "Coverage report not found." >> $GITHUB_STEP_SUMMARY
fi
- name: Comment coverage on PR
if: github.event_name == 'pull_request' && always()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
let body = '## Test Coverage Report\n\n';
try {
const summary = JSON.parse(fs.readFileSync('coverage/coverage-summary.json', 'utf8'));
const total = summary.total;
const fmt = (v) => `${v.pct}%`;
const icon = (v) => v.pct >= 80 ? '✅' : v.pct >= 60 ? '⚠️' : '❌';
body += '| Metric | Coverage | Status |\n';
body += '|--------|----------|--------|\n';
body += `| Statements | ${fmt(total.statements)} | ${icon(total.statements)} |\n`;
body += `| Branches | ${fmt(total.branches)} | ${icon(total.branches)} |\n`;
body += `| Functions | ${fmt(total.functions)} | ${icon(total.functions)} |\n`;
body += `| Lines | ${fmt(total.lines)} | ${icon(total.lines)} |\n`;
} catch (e) {
body += '_Coverage summary not available._\n';
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c =>
c.user.type === 'Bot' && c.body.includes('## Test Coverage Report')
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
filters: |
admin-web:
- 'admin-web/**'
- '.github/workflows/ci.yml'
youtube-worker:
- 'youtube-worker/**'
- '.github/workflows/ci.yml'
supabase-functions:
- 'supabase/functions/**'
- 'youtube-worker/**'
- '.github/workflows/ci.yml'
# ── Admin Web: Next.js ──
admin-web-test:
name: Admin Web Tests
name: Admin Web CI
needs: changes
if: needs.changes.outputs.admin-web == 'true'
runs-on: ubuntu-latest
defaults:
run:
@@ -168,19 +60,22 @@ jobs:
- name: Run unit tests
run: npx vitest run
continue-on-error: true
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test
continue-on-error: true
build-check:
name: Build Check
# ── YouTube Worker: Node.js microservice ──
youtube-worker-check:
name: YouTube Worker
needs: changes
if: needs.changes.outputs.youtube-worker == 'true'
runs-on: ubuntu-latest
needs: [typecheck, lint, test]
defaults:
run:
working-directory: youtube-worker
steps:
- uses: actions/checkout@v4
@@ -189,39 +84,78 @@ jobs:
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: youtube-worker/package-lock.json
- name: Install dependencies
run: npm ci
- name: Export web build
run: npx expo export --platform web
continue-on-error: true
- name: Validate syntax
run: node --check server.js
# ── Deploy: Supabase edge functions + YouTube worker ──
deploy-functions:
name: Deploy Edge Functions
name: Deploy
needs: [changes, admin-web-test, youtube-worker-check]
if: |
always() &&
github.ref == 'refs/heads/main' &&
github.event_name == 'push' &&
!contains(needs.*.result, 'failure') &&
(needs.changes.outputs.supabase-functions == 'true' ||
needs.changes.outputs.youtube-worker == 'true')
runs-on: ubuntu-latest
needs: [typecheck, lint, test]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Deploy to self-hosted Supabase
- name: Setup SSH
env:
DEPLOY_HOST: ${{ secrets.SUPABASE_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.SUPABASE_DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.SUPABASE_DEPLOY_PATH }}
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SUPABASE_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H $DEPLOY_HOST >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy Supabase Edge Functions
env:
DEPLOY_HOST: ${{ secrets.SUPABASE_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.SUPABASE_DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.SUPABASE_DEPLOY_PATH }}
run: |
rsync -avz --delete \
--exclude='node_modules' \
--exclude='.DS_Store' \
-e "ssh -i ~/.ssh/deploy_key" \
supabase/functions/ \
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/"
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" \
"docker restart supabase-edge-functions"
- name: Deploy YouTube Worker
env:
DEPLOY_HOST: ${{ secrets.SUPABASE_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.SUPABASE_DEPLOY_USER }}
WORKER_PATH: /opt/supabase/youtube-worker
run: |
rsync -avz --delete \
--exclude='node_modules' \
--exclude='.DS_Store' \
-e "ssh -i ~/.ssh/deploy_key" \
youtube-worker/ \
"$DEPLOY_USER@$DEPLOY_HOST:$WORKER_PATH/"
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" "\
cd $WORKER_PATH && \
docker build -t youtube-worker:latest . && \
docker stop youtube-worker 2>/dev/null || true && \
docker rm youtube-worker 2>/dev/null || true && \
docker run -d \
--name youtube-worker \
--restart unless-stopped \
--network supabase_supabase-network \
-e SUPABASE_URL=\$(docker exec supabase-edge-functions printenv SUPABASE_URL) \
-e SUPABASE_SERVICE_ROLE_KEY=\$(docker exec supabase-edge-functions printenv SUPABASE_SERVICE_ROLE_KEY) \
-e SUPABASE_PUBLIC_URL=https://supabase.1000co.fr \
-e GEMINI_API_KEY=\$(cat /opt/supabase/.env.gemini 2>/dev/null || echo '') \
-e STORAGE_BUCKET=workout-audio \
-e PORT=3001 \
youtube-worker:latest"

1
.gitignore vendored
View File

@@ -54,3 +54,4 @@ coverage/
node-compile-cache/
.gitnexus
Config/Secrets.xcconfig
tabatago-swift/build/

View File

@@ -449,7 +449,7 @@ Search results can flood context. Use `context-mode_ctx_execute(language: "shell
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relationships, 52 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **tabatago** (3362 symbols, 9407 relationships, 129 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
@@ -461,19 +461,6 @@ This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relation
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/tabatago/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
@@ -481,25 +468,6 @@ This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relation
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
@@ -509,32 +477,6 @@ This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relation
| `gitnexus://repo/tabatago/processes` | All execution flows |
| `gitnexus://repo/tabatago/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |

Some files were not shown because too many files have changed in this diff Show More