Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions App/Features/DirectMessages/DMThreadViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@ final class DMThreadViewModel {
apply(thread, replacing: true)
error = nil
hasLoadedOnce = true
} catch is CancellationError {
// The load was cancelled (view teardown / navigation), not
// failed. Leave `error` and `hasLoadedOnce` untouched so no
// banner shows and no "loaded" flags flip prematurely — the
// surviving poll (or a fresh load) repopulates the thread.
} catch {
self.error = error
hasLoadedOnce = true
Expand Down Expand Up @@ -218,6 +223,12 @@ final class DMThreadViewModel {
}
error = nil
bus?.post(.messageSent(recipientUsername: username, message: sent))
} catch is CancellationError {
// Cancelled mid-send — not a failure. Drop the optimistic bubble
// and restore the draft so the user can retry; a live poll
// reconciles any message that did reach the server. No banner.
messages.removeAll { $0.id == tempId }
draft = priorDraft
} catch {
messages.removeAll { $0.id == tempId }
draft = priorDraft
Expand Down Expand Up @@ -274,6 +285,10 @@ final class DMThreadViewModel {
func pollOnce() async {
do {
let update = try await service.threadUpdates(username: username, since: newestId)
// A successful round-trip proves the thread is live — clear any
// stale error (e.g. a cancelled initial load) so a one-off
// cancellation banner self-heals instead of pinning forever.
error = nil
guard !update.messages.isEmpty || update.otherUser != nil else { return }
mergeUpdates(update)
await markInboundRead()
Expand Down
5 changes: 5 additions & 0 deletions App/Features/DirectMessages/DirectMessagesListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ final class DirectMessagesListViewModel {
regroup()
error = nil
hasLoadedOnce = true
} catch is CancellationError {
// Cancelled (view teardown / folder switch superseded), not
// failed. Leave state untouched so no spurious error banner shows.
} catch {
self.error = error
hasLoadedOnce = true
Expand All @@ -150,6 +153,8 @@ final class DirectMessagesListViewModel {
nextCursor = page.nextCursor
regroup()
error = nil
} catch is CancellationError {
// Cancelled pagination — not a failure; leave the list as-is.
} catch {
self.error = error
}
Expand Down
4 changes: 4 additions & 0 deletions App/Features/DirectMessages/NewMessageViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ final class NewMessageViewModel {
if let username = recipients.first(where: { $0.id == recipientId })?.username {
bus?.post(.messageSent(recipientUsername: username, message: sent))
}
} catch is CancellationError {
// Cancelled mid-send — not a failure. Leave `error` nil so the
// sheet shows no spurious "Network error" banner; the user can
// retry (a persisted message reconciles via the list poll).
} catch {
self.error = error
}
Expand Down
46 changes: 46 additions & 0 deletions AppTests/DMThreadViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,52 @@ final class DMThreadViewModelTests: XCTestCase {
XCTAssertTrue(recorded.isEmpty, "An all-outbound thread marks nothing")
}

// MARK: - Cancellation is not an error

func test_givenLoadCancelled_whenLoading_thenLeavesErrorNilAndNotLoaded() async {
// A cancelled thread load (view teardown / navigation) surfaces as
// CancellationError from the client. It must not pin an error banner
// nor flip `hasLoadedOnce`, which would flash a premature "Not mutual".
let (vm, service, _) = makeViewModel()
await service.enqueueThread(failure: CancellationError())

await vm.load()

XCTAssertNil(vm.error, "A cancelled load must not surface an error")
XCTAssertFalse(vm.hasLoadedOnce, "A cancelled load did not complete")
}

func test_givenSendCancelled_whenSending_thenRestoresDraftWithoutError() async {
// A cancelled send drops the optimistic bubble and restores the draft
// (so the user can retry) but shows no error banner.
let (vm, service, _) = makeViewModel()
vm.seedForTest(messages: [inbound("m1", read: true, at: 100)], otherUser: ada, isMutual: true)
await service.enqueueSend(failure: CancellationError())

vm.draft = "hello"
await vm.send()

XCTAssertEqual(vm.messages.map(\.id), ["m1"], "Optimistic placeholder removed on cancel")
XCTAssertEqual(vm.draft, "hello", "Draft restored for retry")
XCTAssertNil(vm.error, "A cancelled send must not surface an error")
}

func test_givenStaleError_whenPollSucceeds_thenErrorIsCleared() async {
// A genuine earlier failure pins the banner; once a poll round-trips
// successfully the thread is proven live and the banner self-heals.
let (vm, service, _) = makeViewModel()
vm.seedForTest(messages: [inbound("m1", read: true, at: 100)], otherUser: ada, isMutual: true)
await service.enqueueSend(failure: TestError.upstream("offline"))
vm.draft = "hi"
await vm.send()
XCTAssertEqual(vm.error as? TestError, .upstream("offline"), "Precondition: banner is showing")

await service.enqueueThreadUpdates(success: DMThread(messages: [], otherUser: ada, isMutual: true))
await vm.pollOnce()

XCTAssertNil(vm.error, "A successful poll clears the stale error banner")
}

// MARK: - Poll cycle + cancellation

func test_givenPollCycle_whenNewMessageArrives_thenMergesIntoThread() async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,19 @@ public final class APIClient: APIClientProtocol {
)
} catch let error as APIError {
throw error
} catch is CancellationError {
// Cooperative task cancellation (a SwiftUI `.task` torn down on
// view teardown / navigation) is not a network failure. Propagate
// it as-is so callers can ignore it instead of surfacing a
// spurious "Network error: cancelled" banner.
throw CancellationError()
} catch let error as URLError where error.code == .cancelled {
// URLSession's async API reports task cancellation as
// `URLError(.cancelled)`. Normalise it to `CancellationError` so
// the `.cancelled` signal survives the boundary (it would
// otherwise be flattened into `.transport(message: "cancelled")`
// and be indistinguishable from a genuine transport failure).
throw CancellationError()
} catch {
throw APIError.transport(message: error.localizedDescription)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,24 @@ final class APIClientTests: XCTestCase {
}
}

func test_givenCancelledRequest_whenSent_thenThrowsCancellationErrorNotTransport() async throws {
// Given — URLSession's async API reports task cancellation (a SwiftUI
// `.task` torn down on navigation) as `URLError(.cancelled)`.
let (client, transport, _) = makeClient()
await transport.enqueueError(URLError(.cancelled))

// When / Then — it must surface as `CancellationError`, never as a
// user-facing `APIError.transport` ("Network error: cancelled").
do {
_ = try await client.send(Request<Greeting>(method: .get, path: "/x", auth: .none))
XCTFail("Expected cancellation")
} catch is CancellationError {
// Expected.
} catch let error as APIError {
XCTFail("Cancellation must not be mapped to APIError, got \(error)")
}
}

// MARK: - Query parameters

func test_givenOptionalQueryItems_whenSent_thenSkipsNilParameters() async throws {
Expand Down
Loading