swift-code-reviewer-skill 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -493,6 +493,86 @@ struct WaterfallLayout: Layout {
493
493
  }
494
494
  ```
495
495
 
496
+ ### 3.5 Scroll Container Selection
497
+
498
+ **Check for:**
499
+ - [ ] Correct scroll container chosen for the use case
500
+ - [ ] No nested scroll views on the same axis
501
+ - [ ] `List` used for system-style rows with swipe actions; `LazyVStack` for custom layouts
502
+ - [ ] Plain `VStack` only for small, fixed-count content
503
+
504
+ **Decision Table:**
505
+
506
+ | Use Case | Container | Why |
507
+ |----------|-----------|-----|
508
+ | System-style rows, swipe actions, sections | `List` | Built-in lazy loading, cell reuse |
509
+ | Custom row layout, >20 items | `ScrollView + LazyVStack` | Lazy + full layout control |
510
+ | Grid of items | `LazyVGrid` | 2D lazy loading |
511
+ | Small static list (<10 items) | `VStack` | Simplest, no lazy overhead |
512
+ | Horizontal scrolling carousel | `ScrollView(.horizontal) + LazyHStack` | Horizontal lazy |
513
+
514
+ **Examples:**
515
+
516
+ ❌ **Bad: VStack for large dynamic list**
517
+ ```swift
518
+ ScrollView {
519
+ VStack { // ❌ All views created immediately — bad for 100+ items
520
+ ForEach(posts) { post in
521
+ PostRow(post: post)
522
+ }
523
+ }
524
+ }
525
+ ```
526
+
527
+ ❌ **Bad: Nested ScrollViews on same axis**
528
+ ```swift
529
+ ScrollView(.vertical) {
530
+ LazyVStack {
531
+ ForEach(sections) { section in
532
+ ScrollView(.vertical) { // ❌ Nested vertical scroll — broken UX + perf
533
+ ForEach(section.items) { item in
534
+ ItemRow(item: item)
535
+ }
536
+ }
537
+ }
538
+ }
539
+ }
540
+ ```
541
+
542
+ ✅ **Good: List for system-style content**
543
+ ```swift
544
+ List(posts) { post in // ✅ Lazy by default, swipe actions, separators
545
+ PostRow(post: post)
546
+ .swipeActions { Button("Delete", role: .destructive) { delete(post) } }
547
+ }
548
+ .listStyle(.plain)
549
+ ```
550
+
551
+ ✅ **Good: LazyVStack for custom layout feeds**
552
+ ```swift
553
+ ScrollView {
554
+ LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) { // ✅ Lazy + custom layout
555
+ ForEach(posts) { post in
556
+ PostCardView(post: post)
557
+ .padding(.horizontal)
558
+ }
559
+ }
560
+ }
561
+ ```
562
+
563
+ ✅ **Good: LazyVGrid for photo grid**
564
+ ```swift
565
+ let columns = [GridItem(.adaptive(minimum: 100))]
566
+
567
+ ScrollView {
568
+ LazyVGrid(columns: columns, spacing: 2) { // ✅ 2D lazy grid
569
+ ForEach(photos) { photo in
570
+ PhotoThumbnail(photo: photo)
571
+ }
572
+ }
573
+ }
574
+ ```
575
+
496
576
  ---
497
577
 
498
578
  ## 4. Image Performance
@@ -859,6 +939,115 @@ VStack {
859
939
 
860
940
  ---
861
941
 
942
+ ## 8. Narrow Observation Scope
943
+
944
+ ### 8.1 Read Only What You Display
945
+
946
+ **Check for:**
947
+ - [ ] Views read only the properties they actually display (not entire `@Observable` objects passed unnecessarily)
948
+ - [ ] Subviews receive minimal data needed (IDs or value types when possible, not full observable models)
949
+ - [ ] `@Observable` objects not passed to descendants that don't observe them
950
+
951
+ **Examples:**
952
+
953
+ ❌ **Bad: Passing entire observable to unrelated subview**
954
+ ```swift
955
+ @Observable
956
+ final class FeedViewModel {
957
+ var posts: [Post] = []
958
+ var isLoading: Bool = false
959
+ var currentUser: User = .placeholder
960
+ var unreadCount: Int = 0
961
+ // ... many more properties
962
+ }
963
+
964
+ struct FeedView: View {
965
+ let viewModel: FeedViewModel
966
+
967
+ var body: some View {
968
+ List(viewModel.posts) { post in
969
+ PostRow(viewModel: viewModel, post: post) // ❌ Passes entire ViewModel
970
+ }
971
+ }
972
+ }
973
+
974
+ struct PostRow: View {
975
+ let viewModel: FeedViewModel // ❌ Observes ALL viewModel properties, re-renders on any change
976
+ let post: Post
977
+
978
+ var body: some View {
979
+ Text(post.title) // Only uses post, but re-renders on isLoading, unreadCount changes
980
+ }
981
+ }
982
+ ```
983
+
984
+ ✅ **Good: Pass only what the subview needs**
985
+ ```swift
986
+ struct FeedView: View {
987
+ let viewModel: FeedViewModel
988
+
989
+ var body: some View {
990
+ List(viewModel.posts) { post in
991
+ PostRow(post: post) // ✅ Pass only the value, not the entire observable
992
+ }
993
+ }
994
+ }
995
+
996
+ struct PostRow: View {
997
+ let post: Post // ✅ Only observes/depends on this post
998
+
999
+ var body: some View {
1000
+ Text(post.title)
1001
+ }
1002
+ }
1003
+ ```
1004
+
1005
+ ### 8.2 Lazy Containers for Feeds
1006
+
1007
+ **Check for:**
1008
+ - [ ] Feeds with >20 items use lazy containers (`List`, `LazyVStack`, `LazyVGrid`)
1009
+ - [ ] Row views are lightweight (no heavy init, no async work in init)
1010
+ - [ ] `.task` or `.onAppear` on rows used for per-row async loading (e.g., avatar images)
1011
+
1012
+ **Examples:**
1013
+
1014
+ ❌ **Bad: Eager loading all rows**
1015
+ ```swift
1016
+ ScrollView {
1017
+ VStack { // ❌ Instantiates all PostRow views immediately
1018
+ ForEach(viewModel.posts) { post in
1019
+ PostRow(post: post)
1020
+ }
1021
+ }
1022
+ }
1023
+ ```
1024
+
1025
+ ✅ **Good: Lazy container with lightweight rows**
1026
+ ```swift
1027
+ List(viewModel.posts) { post in // ✅ Lazy — only creates visible rows
1028
+ PostRow(post: post)
1029
+ }
1030
+
1031
+ struct PostRow: View {
1032
+ let post: Post
1033
+ @State private var avatar: Image?
1034
+
1035
+ var body: some View {
1036
+ HStack {
1037
+ (avatar ?? Image(systemName: "person.circle"))
1038
+ .resizable()
1039
+ .frame(width: 40, height: 40)
1040
+ Text(post.content)
1041
+ }
1042
+ .task { // ✅ Load avatar lazily only when row appears
1043
+ avatar = await loadAvatar(url: post.avatarURL)
1044
+ }
1045
+ }
1046
+ }
1047
+ ```
1048
+
1049
+ ---
1050
+
862
1051
  ## Quick Performance Checklist
863
1052
 
864
1053
  ### Critical (Fix Immediately)
@@ -873,6 +1062,8 @@ VStack {
873
1062
  - [ ] AsyncImage for remote images
874
1063
  - [ ] LazyVStack/LazyHStack for large lists
875
1064
  - [ ] Minimal GeometryReader usage
1065
+ - [ ] Correct scroll container selected (List vs LazyVStack vs LazyVGrid vs VStack)
1066
+ - [ ] No nested scroll views on the same axis
876
1067
 
877
1068
  ### Medium Priority
878
1069
  - [ ] Computed properties for derived data
@@ -880,6 +1071,8 @@ VStack {
880
1071
  - [ ] Proper task cancellation
881
1072
  - [ ] Efficient image sizing
882
1073
  - [ ] Resource cleanup in deinit
1074
+ - [ ] Narrow observation scope (subviews receive only needed data, not full @Observable)
1075
+ - [ ] @Observable not propagated to unrelated descendants
883
1076
 
884
1077
  ### Low Priority
885
1078
  - [ ] Animation performance optimization
@@ -521,6 +521,127 @@ Button("Log In") {
521
521
  - [ ] Keyboard navigation support
522
522
  - [ ] VoiceOver tested (if critical UI)
523
523
 
524
+ ### Step 2.2B: Navigation & UI Architecture Check
525
+
526
+ **Reference**: `swiftui-ui-patterns` skill, `references/swiftui-review-checklist.md` (Sections 8–14)
527
+
528
+ Apply this step when reviewing views that contain navigation, sheets, TabView, or async state loading.
529
+
530
+ #### Route Enum & RouterPath
531
+ ```swift
532
+ // ✅ Good: Typed route enum + RouterPath
533
+ enum AppRoute: Hashable {
534
+ case userDetail(userID: UUID)
535
+ case settings
536
+ }
537
+
538
+ @Observable final class RouterPath {
539
+ var path: [AppRoute] = []
540
+ func navigate(to route: AppRoute) { path.append(route) }
541
+ }
542
+ ```
543
+
544
+ **Checks:**
545
+ - [ ] Route destinations defined as typed `Hashable` enum (not String/Int raw values)
546
+ - [ ] `RouterPath` `@Observable` owns the navigation path (not ad-hoc `@State var path`)
547
+ - [ ] Single `.navigationDestination(for: Route.self)` per `NavigationStack` (in root view, not child views)
548
+
549
+ #### Sheet Routing
550
+ ```swift
551
+ // ✅ Good: Item-driven + SheetDestination enum
552
+ enum SheetDestination: Identifiable { case compose; case profile(UUID); var id: String { ... } }
553
+
554
+ @State private var sheetDestination: SheetDestination?
555
+ .sheet(item: $sheetDestination) { destination in
556
+ switch destination { case .compose: ...; case .profile(let id): ... }
557
+ }
558
+ ```
559
+
560
+ **Checks:**
561
+ - [ ] `.sheet(item:)` preferred over `.sheet(isPresented:)` when a model is selected
562
+ - [ ] Multiple sheets use a single `SheetDestination` `Identifiable` enum (not multiple `@State Bool`)
563
+ - [ ] No multiple `.sheet(isPresented:)` modifiers on the same view for different destinations
564
+
565
+ #### TabView Architecture
566
+ ```swift
567
+ // ✅ Good: Independent RouterPath per tab
568
+ @Observable final class AppTabRouter {
569
+ var homeRouter = RouterPath()
570
+ var searchRouter = RouterPath()
571
+ func router(for tab: AppTab) -> RouterPath { ... }
572
+ }
573
+ ```
574
+
575
+ **Checks:**
576
+ - [ ] Each tab has its own `RouterPath` (not a single shared `NavigationPath`)
577
+ - [ ] Tab switching preserves navigation history in each tab
578
+ - [ ] Action tabs (compose, post) handled via side effect (modal), not actual tab destination
579
+
580
+ #### Deep Link Handling
581
+ ```swift
582
+ // ✅ Good: Centralized at root
583
+ .onOpenURL { url in router.handle(url: url) } // In root view only
584
+ ```
585
+
586
+ **Checks:**
587
+ - [ ] `.onOpenURL` applied at app root (not in feature views)
588
+ - [ ] URL parsing handled in router/coordinator, not in views
589
+ - [ ] Deep link routes to the correct tab router when per-tab navigation is used
590
+
591
+ #### Theming
592
+ ```swift
593
+ // ✅ Good: Semantic colors via Theme
594
+ @Environment(Theme.self) private var theme
595
+ Text(title).foregroundStyle(theme.labelPrimary)
596
+ ```
597
+
598
+ **Checks:**
599
+ - [ ] No raw `Color.blue`, `Color.white`, `Color.red` when a project `Theme` object exists
600
+ - [ ] Colors accessed via `@Environment(Theme.self)` or equivalent design token
601
+ - [ ] `Theme` provided at app root via `.environment(theme)`
602
+
603
+ #### Async State Patterns
604
+ ```swift
605
+ // ✅ Good: .task(id:) with debounce + CancellationError silenced
606
+ .task(id: query) {
607
+ do {
608
+ try await Task.sleep(for: .milliseconds(300)) // Debounce
609
+ results = try await search(query)
610
+ } catch is CancellationError { } // Silenced
611
+ catch { /* real error */ }
612
+ }
613
+
614
+ // ✅ Good: LoadState enum
615
+ enum LoadState<T> { case idle, loading, loaded(T), error(Error) }
616
+ ```
617
+
618
+ **Checks:**
619
+ - [ ] `.task(id:)` used for input-driven async work (not `.onChange + Task { }`)
620
+ - [ ] `CancellationError` silenced when using `.task(id:)` (not shown to user)
621
+ - [ ] `LoadState<T>` enum used instead of multiple `isLoading`/`hasError` booleans
622
+
623
+ #### Focus and Input
624
+ ```swift
625
+ // ✅ Good: FocusField enum with chaining
626
+ enum FocusField { case email, password }
627
+ @FocusState private var focusedField: FocusField?
628
+
629
+ TextField("Email", text: $email)
630
+ .focused($focusedField, equals: .email)
631
+ .onSubmit { focusedField = .password } // Chain to next
632
+
633
+ SecureField("Password", text: $password)
634
+ .focused($focusedField, equals: .password)
635
+ .onSubmit { submitForm() } // Last field submits
636
+ ```
637
+
638
+ **Checks:**
639
+ - [ ] `FocusField` enum used with `@FocusState` (not multiple `@FocusState private var isFocused: Bool`)
640
+ - [ ] `.onSubmit` advances focus to the next field in sequence
641
+ - [ ] Last field's `.onSubmit` triggers the primary action (login, search, etc.)
642
+
643
+ ---
644
+
524
645
  ### Step 2.3: Performance Check
525
646
 
526
647
  **Reference**: `swiftui-performance-audit`