Commit 8e757f5557

8e757f55578b014df20515bb518bb339ba72156f

parent: 5e81daed1a

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-06 19:36 UTC

Fix rate-limited typing and a cancellation flash on the filtered lists

Author and assignee are free text, same as search: the didSet debounce
comparison now copies all three onto oldValue before deciding whether a
change is a picker (reload now) or typing (debounce), so an 8-character
username no longer fires eight immediate reads.

PagedListModel.fetch no longer writes .failed when its own task was
cancelled — a fast filter change cancels the superseded reload, whose
aborted request must not paint a stale "check your connection" banner
over the replacement's result. load() in both list view models now
cancels any pending reloadTask first, so pull-to-refresh mid-type can't
race a stale debounce.

Also widens the margins on the existing debounce test: Task.sleep
guarantees a minimum wait, not a maximum, so sleeping between simulated
keystrokes was a flake window on a loaded machine. Typing the whole
string with no intervening sleep removes it.

Layout: unified · split

gitbay/Issues/IssueListViewModel.swift +6 −2
@@ -13,10 +13,13 @@ final class IssueListViewModel {
1313 didSet {
1414 guard filter != oldValue else { return }
1515 configureList()
16 // Only the text changed: debounce, so typing does not fire a
17 // request per keystroke. Anything else is a picker: reload now.
16 // Only the free-text fields changed: debounce, so typing does
17 // not fire a request per keystroke. Anything else — a picker —
18 // reloads now.
1819 var typed = oldValue
1920 typed.search = filter.search
21 typed.author = filter.author
22 typed.assignee = filter.assignee
2023 if typed == filter {
2124 scheduleSearchReload()
2225 } else {
@@ -52,6 +55,7 @@ final class IssueListViewModel {
5255 var state: LoadState<[Issue]> { list.state }
5356
5457 func load() async {
58 reloadTask?.cancel()
5559 await list.reload()
5660 if case .failed = list.state { return }
5761 guard !labelsLoaded else { return }
gitbay/MRs/MRListViewModel.swift +6 −2
@@ -13,10 +13,13 @@ final class MRListViewModel {
1313 didSet {
1414 guard filter != oldValue else { return }
1515 configureList()
16 // Only the text changed: debounce, so typing does not fire a
17 // request per keystroke. Anything else is a picker: reload now.
16 // Only the free-text fields changed: debounce, so typing does
17 // not fire a request per keystroke. Anything else — a picker —
18 // reloads now. MRFilter has no `assignee` field: `mr list`
19 // rejects it.
1820 var typed = oldValue
1921 typed.search = filter.search
22 typed.author = filter.author
2023 if typed == filter {
2124 scheduleSearchReload()
2225 } else {
@@ -43,6 +46,7 @@ final class MRListViewModel {
4346 var state: LoadState<[MergeRequest]> { list.state }
4447
4548 func load() async {
49 reloadTask?.cancel()
4650 await list.reload()
4751 }
4852
gitbay/Repos/PagedListModel.swift +5 −2
@@ -59,8 +59,11 @@ final class PagedListModel<Element: Decodable & Sendable> {
5959 } catch {
6060 // A failed first page is the screen's state; a failed later
6161 // page keeps what is on screen and leaves the cursor for a
62 // retry from the same trigger.
63 if existing.isEmpty {
62 // retry from the same trigger. A cancelled fetch (superseded
63 // by a newer reload, e.g. a fast filter change) is neither —
64 // it must not paint a stale failure over whatever the
65 // replacement fetch already showed or is about to show.
66 if existing.isEmpty, !Task.isCancelled {
6467 state = .from(error)
6568 }
6669 }
gitbayTests/ListFilterTests.swift +117 −2
@@ -146,6 +146,11 @@ private let emptyPage = """
146146private let noLabels = """
147147 {"protocol_version":1,"data":[],"exit_code":0}
148148 """
149private let pageWithCursor = """
150 {"protocol_version":1,"data":{"items":[\
151 {"number":7,"title":"a bug","state":"open","author":"cmc",\
152 "created_at":"2026-09-01T00:00:00Z"}],"next":"c1"},"exit_code":0}
153 """
149154
150155/// Every `argv=` value from a request URL, in order.
151156private func argvFrom(_ url: URL) -> [String] {
@@ -198,17 +203,106 @@ struct IssueListFilterWiringTests {
198203 await model.load()
199204
200205 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
206 // No sleeps between keystrokes: `Task.sleep` guarantees a minimum
207 // wait, not a maximum, so any inter-keystroke delay is a window
208 // for the debounce timer to fire early on a loaded machine. Typing
209 // the whole string with no `await` between assignments removes
210 // that window; only the final wait — comfortably past the 300ms
211 // debounce — needs headroom.
201212 for text in ["c", "cr", "cra", "cras", "crash"] {
202213 model.filter.search = text
203 try await Task.sleep(for: .milliseconds(20))
204214 }
205 try await Task.sleep(for: .milliseconds(500))
215 try await Task.sleep(for: .milliseconds(600))
206216
207217 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
208218 #expect(reads.count == 2)
209219 #expect(argvFrom(try #require(reads.last).url).contains("crash"))
210220 }
211221
222 /// Typing into `author` must debounce too — it is free text, exactly
223 /// like `search`, and firing a request per keystroke would hit the
224 /// rate limit on an 8-character username.
225 @Test func authorDebouncesIntoOneRead() async throws {
226 let (client, stub) = try makeClient()
227 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
228 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
229 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
230 await model.load()
231
232 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
233 for text in ["c", "cm", "cmc"] {
234 model.filter.author = text
235 }
236 try await Task.sleep(for: .milliseconds(600))
237
238 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
239 #expect(reads.count == 2)
240 let argv = argvFrom(try #require(reads.last).url)
241 #expect(argv.contains("--author"))
242 #expect(argv.contains("cmc"))
243 }
244
245 /// Same for `assignee`, issue-list-only.
246 @Test func assigneeDebouncesIntoOneRead() async throws {
247 let (client, stub) = try makeClient()
248 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
249 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
250 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
251 await model.load()
252
253 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
254 for text in ["c", "cm", "cmc"] {
255 model.filter.assignee = text
256 }
257 try await Task.sleep(for: .milliseconds(600))
258
259 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
260 #expect(reads.count == 2)
261 let argv = argvFrom(try #require(reads.last).url)
262 #expect(argv.contains("--assignee"))
263 #expect(argv.contains("cmc"))
264 }
265
266 /// A filter change reloads rather than pages, so it must not carry
267 /// forward a cursor from whatever page was on screen.
268 @Test func aFilterChangeDoesNotCarryAStaleCursor() async throws {
269 let (client, stub) = try makeClient()
270 stub.enqueue(.init(status: 200, json: pageWithCursor, match: "argv=issue"))
271 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
272 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
273 await model.load()
274 #expect(model.list.hasMore)
275
276 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
277 model.filter.label = "bug"
278 try await Task.sleep(for: .milliseconds(150))
279
280 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
281 let last = try #require(reads.last)
282 #expect(!argvFrom(last.url).contains("--cursor"))
283 }
284
285 /// Two rapid picker changes cancel the first reload's task. The
286 /// cancelled fetch must not paint a bogus "check your connection"
287 /// failure over the correct state the second reload lands.
288 @Test func rapidFilterChangesDoNotLeaveTheScreenFailed() async throws {
289 let (client, stub) = try makeClient()
290 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
291 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
292 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
293 await model.load()
294
295 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
296 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
297 model.filter.label = "bug"
298 model.filter.label = "feature"
299 try await Task.sleep(for: .milliseconds(200))
300
301 if case .failed(let message) = model.state {
302 Testing.Issue.record("rapid filter changes left the screen failed: \(message)")
303 }
304 }
305
212306 @Test func theEmptyMessageComesFromTheFilter() async throws {
213307 let (client, stub) = try makeClient()
214308 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
@@ -243,6 +337,27 @@ struct MRListFilterWiringTests {
243337 #expect(argv.contains("retarget"))
244338 }
245339
340 /// Typing into `author` must debounce into one read, same as `search`
341 /// — and, unlike the other tests in this struct, actually count the
342 /// requests rather than just inspecting the last one.
343 @Test func authorDebouncesIntoOneRead() async throws {
344 let (client, stub) = try makeClient()
345 stub.enqueue(.init(status: 200, json: emptyPage))
346 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
347 await model.load()
348
349 stub.enqueue(.init(status: 200, json: emptyPage))
350 for text in ["c", "cm", "cmc"] {
351 model.filter.author = text
352 }
353 try await Task.sleep(for: .milliseconds(600))
354
355 #expect(stub.seen.count == 2)
356 let argv = argvFrom(try #require(stub.seen.last).url)
357 #expect(argv.contains("--author"))
358 #expect(argv.contains("cmc"))
359 }
360
246361 @Test func sourceGoneReachesTheCommandInItsWireSpelling() async throws {
247362 let (client, stub) = try makeClient()
248363 stub.enqueue(.init(status: 200, json: emptyPage))
gitbayTests/PagedListTests.swift +16
@@ -111,6 +111,22 @@ struct PagedListModelTests {
111111 #expect(list.state.value?.map(\.number) == [1, 2, 3])
112112 }
113113
114 /// A cancelled reload (e.g. superseded by a newer one from a fast
115 /// filter change) must not write a `.failed` state — whatever error
116 /// the cancellation surfaces as, it belongs to a fetch nobody wants
117 /// the result of any more.
118 @Test func aCancelledReloadDoesNotWriteAFailedState() async throws {
119 let (list, _) = try makeList()
120
121 let task = Task { await list.reload() }
122 task.cancel()
123 await task.value
124
125 if case .failed(let message) = list.state {
126 Issue.record("cancelled reload left the screen failed: \(message)")
127 }
128 }
129
114130 @Test func reloadDropsTheOldCursorAndItems() async throws {
115131 let (list, stub) = try makeList()
116132 stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1")))