Commit 14fd4b36ec

14fd4b36ec5620b173638c57b94fb642650ab66a

parent: 9e6ec71086

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 18:35 UTC

perf: cache filtered collections and add equatable row views

Convert filteredJobs, filteredPastes, and filteredTickets from computed
properties to stored properties updated via didSet on their inputs. Each
update function guards with an equality check before assigning, so
@Observable skips the notification when the filtered result hasn't
changed — preventing list re-renders on auto-refresh when no visible
data has actually changed.

Add Equatable conformance to BuildRowView, PasteRowView, TicketRowView,
and SelectableTicketRow, and apply .equatable() at each ForEach call
site. When a list does re-render, SwiftUI now skips body evaluation for
rows whose model value is identical to the previous pass.

Implements: https://todo.sr.ht/~ccleberg/hutch/57

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -515,7 +515,7 @@
515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517 CODE_SIGN_STYLE = Automatic; 517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 61; 518 CURRENT_PROJECT_VERSION = 62;
519 DEVELOPMENT_TEAM = ZCNAX3VL9D; 519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520 ENABLE_PREVIEWS = YES; 520 ENABLE_PREVIEWS = YES;
521 GENERATE_INFOPLIST_FILE = YES; 521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532 "$(inherited)", 532 "$(inherited)",
533 "@executable_path/Frameworks", 533 "@executable_path/Frameworks",
534 ); 534 );
535 MARKETING_VERSION = 3.0.0; 535 MARKETING_VERSION = 3.0.1;
536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537 PRODUCT_NAME = "$(TARGET_NAME)"; 537 PRODUCT_NAME = "$(TARGET_NAME)";
538 STRING_CATALOG_GENERATE_SYMBOLS = YES; 538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554 CODE_SIGN_STYLE = Automatic; 554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 61; 555 CURRENT_PROJECT_VERSION = 62;
556 DEVELOPMENT_TEAM = ZCNAX3VL9D; 556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557 ENABLE_PREVIEWS = YES; 557 ENABLE_PREVIEWS = YES;
558 GENERATE_INFOPLIST_FILE = YES; 558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569 "$(inherited)", 569 "$(inherited)",
570 "@executable_path/Frameworks", 570 "@executable_path/Frameworks",
571 ); 571 );
572 MARKETING_VERSION = 3.0.0; 572 MARKETING_VERSION = 3.0.1;
573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574 PRODUCT_NAME = "$(TARGET_NAME)"; 574 PRODUCT_NAME = "$(TARGET_NAME)";
575 STRING_CATALOG_GENERATE_SYMBOLS = YES; 575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632 APPLICATION_EXTENSION_API_ONLY = YES; 632 APPLICATION_EXTENSION_API_ONLY = YES;
633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634 CODE_SIGN_STYLE = Automatic; 634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 61; 635 CURRENT_PROJECT_VERSION = 62;
636 DEVELOPMENT_TEAM = ZCNAX3VL9D; 636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637 GENERATE_INFOPLIST_FILE = NO; 637 GENERATE_INFOPLIST_FILE = NO;
638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642 "@executable_path/Frameworks", 642 "@executable_path/Frameworks",
643 "@executable_path/../../Frameworks", 643 "@executable_path/../../Frameworks",
644 ); 644 );
645 MARKETING_VERSION = 3.0.0; 645 MARKETING_VERSION = 3.0.1;
646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647 PRODUCT_NAME = "$(TARGET_NAME)"; 647 PRODUCT_NAME = "$(TARGET_NAME)";
648 SKIP_INSTALL = YES; 648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661 APPLICATION_EXTENSION_API_ONLY = YES; 661 APPLICATION_EXTENSION_API_ONLY = YES;
662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663 CODE_SIGN_STYLE = Automatic; 663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 61; 664 CURRENT_PROJECT_VERSION = 62;
665 DEVELOPMENT_TEAM = ZCNAX3VL9D; 665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666 GENERATE_INFOPLIST_FILE = NO; 666 GENERATE_INFOPLIST_FILE = NO;
667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671 "@executable_path/Frameworks", 671 "@executable_path/Frameworks",
672 "@executable_path/../../Frameworks", 672 "@executable_path/../../Frameworks",
673 ); 673 );
674 MARKETING_VERSION = 3.0.0; 674 MARKETING_VERSION = 3.0.1;
675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676 PRODUCT_NAME = "$(TARGET_NAME)"; 676 PRODUCT_NAME = "$(TARGET_NAME)";
677 SKIP_INSTALL = YES; 677 SKIP_INSTALL = YES;
Hutch/Views/Builds/BuildListView.swift +1
@@ -137,6 +137,7 @@ struct BuildListView: View {
137 ForEach(viewModel.filteredJobs) { job in 137 ForEach(viewModel.filteredJobs) { job in
138 NavigationLink(value: job) { 138 NavigationLink(value: job) {
139 BuildRowView(job: job) 139 BuildRowView(job: job)
140 .equatable()
140 } 141 }
141 .contextMenu { 142 .contextMenu {
142 Button { 143 Button {
Hutch/Views/Builds/BuildListViewModel.swift +27 −8
@@ -46,18 +46,33 @@ enum AutoRefreshInterval: Int, CaseIterable, Sendable {
46final class BuildListViewModel { 46final class BuildListViewModel {
47 private static let searchHistoryScopeID = "builds" 47 private static let searchHistoryScopeID = "builds"
48 48
49 private(set) var jobs: [JobSummary] = [] 49 private(set) var jobs: [JobSummary] = [] {
50 didSet { updateFilteredJobs() }
51 }
50 private(set) var recentSearches: [ScopedSearchHistoryEntry] 52 private(set) var recentSearches: [ScopedSearchHistoryEntry]
51 private(set) var isLoading = false 53 private(set) var isLoading = false
52 private(set) var isLoadingMore = false 54 private(set) var isLoadingMore = false
53 private(set) var isRefreshing = false 55 private(set) var isRefreshing = false
54 private(set) var isSubmitting = false 56 private(set) var isSubmitting = false
55 var error: String? 57 var error: String?
56 var filter: BuildListFilter = .attention 58 var filter: BuildListFilter = .attention {
57 var searchText = "" 59 didSet { updateFilteredJobs() }
60 }
61 var searchText = "" {
62 didSet { updateFilteredJobs() }
63 }
58 var repoFilter: String = "" { 64 var repoFilter: String = "" {
59 didSet { if repoFilter != oldValue { repoFilterDidChange() } } 65 didSet {
66 guard repoFilter != oldValue else { return }
67 repoFilterDidChange()
68 updateFilteredJobs()
69 }
60 } 70 }
71 // Cached filtered result. Updated whenever jobs, filter, searchText, or
72 // repoFilter changes. Only notifies observers when the content actually
73 // differs, which prevents the list from re-rendering on auto-refresh when
74 // no visible data changed.
75 private(set) var filteredJobs: [JobSummary] = []
61 76
62 private var cursor: String? 77 private var cursor: String?
63 private var hasMore = true 78 private var hasMore = true
@@ -83,14 +98,18 @@ final class BuildListViewModel {
83 return allTags.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } 98 return allTags.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
84 } 99 }
85 100
86 var filteredJobs: [JobSummary] { 101 private func updateFilteredJobs() {
87 var result = Self.filterJobs(jobs, filter: filter) 102 var result = Self.filterJobs(jobs, filter: filter)
88
89 if !repoFilter.isEmpty { 103 if !repoFilter.isEmpty {
90 result = result.filter { $0.tags.contains(repoFilter) } 104 result = result.filter { $0.tags.contains(repoFilter) }
91 } 105 }
92 106 let updated = Self.searchJobs(result, matching: searchText)
93 return Self.searchJobs(result, matching: searchText) 107 // Skip the assignment (and the resulting observer notification) when the
108 // filtered list hasn't actually changed — e.g. on auto-refresh when no
109 // builds have been added or updated.
110 if updated != filteredJobs {
111 filteredJobs = updated
112 }
94 } 113 }
95 114
96 // MARK: - Auto-Refresh 115 // MARK: - Auto-Refresh
Hutch/Views/Builds/BuildRowView.swift +1 −1
@@ -1,6 +1,6 @@
1import SwiftUI 1import SwiftUI
2 2
3struct BuildRowView: View { 3struct BuildRowView: View, Equatable {
4 let job: JobSummary 4 let job: JobSummary
5 5
6 var body: some View { 6 var body: some View {
Hutch/Views/Pastes/PasteListView.swift +2 −1
@@ -73,6 +73,7 @@ struct PasteListView: View {
73 ForEach(viewModel.filteredPastes) { paste in 73 ForEach(viewModel.filteredPastes) { paste in
74 NavigationLink(value: paste) { 74 NavigationLink(value: paste) {
75 PasteRowView(paste: paste) 75 PasteRowView(paste: paste)
76 .equatable()
76 } 77 }
77 .swipeActions(edge: .leading, allowsFullSwipe: true) { 78 .swipeActions(edge: .leading, allowsFullSwipe: true) {
78 if swipeActionsEnabled { 79 if swipeActionsEnabled {
@@ -208,7 +209,7 @@ struct PasteListView: View {
208 } 209 }
209} 210}
210 211
211private struct PasteRowView: View { 212private struct PasteRowView: View, Equatable {
212 let paste: Paste 213 let paste: Paste
213 214
214 var body: some View { 215 var body: some View {
Hutch/Views/Pastes/PasteListViewModel.swift +20 −8
@@ -3,13 +3,18 @@ import Foundation
3@Observable 3@Observable
4@MainActor 4@MainActor
5final class PasteListViewModel { 5final class PasteListViewModel {
6 private(set) var pastes: [Paste] = [] 6 private(set) var pastes: [Paste] = [] {
7 didSet { updateFilteredPastes() }
8 }
7 private(set) var isLoading = false 9 private(set) var isLoading = false
8 private(set) var isLoadingMore = false 10 private(set) var isLoadingMore = false
9 private(set) var isRefreshing = false 11 private(set) var isRefreshing = false
10 private(set) var isCreatingPaste = false 12 private(set) var isCreatingPaste = false
11 var error: String? 13 var error: String?
12 var searchText = "" 14 var searchText = "" {
15 didSet { updateFilteredPastes() }
16 }
17 private(set) var filteredPastes: [Paste] = []
13 18
14 private var cursor: String? 19 private var cursor: String?
15 private var hasMore = true 20 private var hasMore = true
@@ -19,15 +24,22 @@ final class PasteListViewModel {
19 self.service = service 24 self.service = service
20 } 25 }
21 26
22 var filteredPastes: [Paste] { 27 private func updateFilteredPastes() {
23 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() 28 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
24 guard !q.isEmpty else { return pastes } 29 let updated: [Paste]
25 return pastes.filter { 30 if q.isEmpty {
26 $0.files.contains { 31 updated = pastes
27 ($0.filename?.lowercased().contains(q) == true) || 32 } else {
28 $0.hash.lowercased().hasPrefix(q) 33 updated = pastes.filter {
34 $0.files.contains {
35 ($0.filename?.lowercased().contains(q) == true) ||
36 $0.hash.lowercased().hasPrefix(q)
37 }
29 } 38 }
30 } 39 }
40 if updated != filteredPastes {
41 filteredPastes = updated
42 }
31 } 43 }
32 44
33 func loadPastes() async { 45 func loadPastes() async {
Hutch/Views/Tickets/TicketListView.swift +4 −2
@@ -290,6 +290,7 @@ struct TicketListView: View {
290 ticket: ticket, 290 ticket: ticket,
291 isSelected: viewModel.selectedTicketIDs.contains(ticket.id) 291 isSelected: viewModel.selectedTicketIDs.contains(ticket.id)
292 ) 292 )
293 .equatable()
293 } 294 }
294 .buttonStyle(.plain) 295 .buttonStyle(.plain)
295 } else { 296 } else {
@@ -303,6 +304,7 @@ struct TicketListView: View {
303 ) 304 )
304 } label: { 305 } label: {
305 TicketRowView(ticket: ticket) 306 TicketRowView(ticket: ticket)
307 .equatable()
306 } 308 }
307 .contextMenu { 309 .contextMenu {
308 if let url = SRHTWebURL.ticket(ownerUsername: ownerUsername(for: tracker), trackerName: tracker.name, ticketId: ticket.id) { 310 if let url = SRHTWebURL.ticket(ownerUsername: ownerUsername(for: tracker), trackerName: tracker.name, ticketId: ticket.id) {
@@ -1180,7 +1182,7 @@ private struct BulkAssignSheet: View {
1180 } 1182 }
1181} 1183}
1182 1184
1183private struct SelectableTicketRow: View { 1185private struct SelectableTicketRow: View, Equatable {
1184 let ticket: TicketSummary 1186 let ticket: TicketSummary
1185 let isSelected: Bool 1187 let isSelected: Bool
1186 1188
@@ -1200,7 +1202,7 @@ private struct SelectableTicketRow: View {
1200 1202
1201// MARK: - Ticket Row 1203// MARK: - Ticket Row
1202 1204
1203private struct TicketRowView: View { 1205private struct TicketRowView: View, Equatable {
1204 let ticket: TicketSummary 1206 let ticket: TicketSummary
1205 1207
1206 var body: some View { 1208 var body: some View {
Hutch/Views/Tickets/TicketListViewModel.swift +15 −5
@@ -83,7 +83,9 @@ final class TicketListViewModel {
83 let trackerId: Int 83 let trackerId: Int
84 let trackerRid: String 84 let trackerRid: String
85 85
86 private(set) var tickets: [TicketSummary] = [] 86 private(set) var tickets: [TicketSummary] = [] {
87 didSet { updateFilteredTickets() }
88 }
87 private(set) var isLoading = false 89 private(set) var isLoading = false
88 private(set) var isLoadingMore = false 90 private(set) var isLoadingMore = false
89 private(set) var isCreatingTicket = false 91 private(set) var isCreatingTicket = false
@@ -97,15 +99,21 @@ final class TicketListViewModel {
97 var filter: TicketFilter = .open { 99 var filter: TicketFilter = .open {
98 didSet { 100 didSet {
99 persistFilterState() 101 persistFilterState()
102 updateFilteredTickets()
100 } 103 }
101 } 104 }
102 var selectedLabelIDs: Set<Int> = [] { 105 var selectedLabelIDs: Set<Int> = [] {
103 didSet { 106 didSet {
104 persistFilterState() 107 persistFilterState()
108 updateFilteredTickets()
105 } 109 }
106 } 110 }
107 var searchText = "" 111 var searchText = "" {
112 didSet { updateFilteredTickets() }
113 }
108 private(set) var activeSavedFilterID: SavedTicketFilter.ID? 114 private(set) var activeSavedFilterID: SavedTicketFilter.ID?
115 // Cached filtered result. See updateFilteredTickets().
116 private(set) var filteredTickets: [TicketSummary] = []
109 117
110 private var cursor: String? 118 private var cursor: String?
111 private var hasMore = true 119 private var hasMore = true
@@ -268,9 +276,11 @@ final class TicketListViewModel {
268 !currentFilterState.isDefault 276 !currentFilterState.isDefault
269 } 277 }
270 278
271 /// Tickets filtered by the selected status and label filters. 279 private func updateFilteredTickets() {
272 var filteredTickets: [TicketSummary] { 280 let updated = Self.filterTickets(tickets, state: currentFilterState, query: searchText)
273 Self.filterTickets(tickets, state: currentFilterState, query: searchText) 281 if updated != filteredTickets {
282 filteredTickets = updated
283 }
274 } 284 }
275 285
276 // MARK: - Public API 286 // MARK: - Public API