From 1bf7c9ae92bfdbaa8b32e3f69e5e0b5e58a3ff81 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Fri, 11 Sep 2026 17:33:54 +1000 Subject: [PATCH] fix(swift-ios): ignore stale device reads after revocation A device list reload that finishes after a revoke could put the removed device back on screen. Each reload and revoke now takes a generation, and only the newest operation may write results or clear its spinner. Co-Authored-By: Claude Opus 5 (1M context) --- .../Features/Devices/DevicesView.swift | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/apps/swift-ios/Features/Devices/DevicesView.swift b/apps/swift-ios/Features/Devices/DevicesView.swift index 59a2a80a612e..49d172d19cbf 100644 --- a/apps/swift-ios/Features/Devices/DevicesView.swift +++ b/apps/swift-ios/Features/Devices/DevicesView.swift @@ -9,6 +9,7 @@ public struct DevicesView: View { @State private var errorMessage: String? @State private var revokeTarget: FeatureDeviceSession? @State private var showingRevokeOthers = false + @State private var operationGeneration: UInt64 = 0 public init(manager: any FeatureDeviceManaging) { self.manager = manager @@ -174,43 +175,65 @@ public struct DevicesView: View { @MainActor private func reload() async { + guard !isRevoking else { return } + operationGeneration &+= 1 + let generation = operationGeneration isLoading = true - defer { isLoading = false } + defer { + if operationGeneration == generation { isLoading = false } + } do { - sessions = FeatureDeviceSession.sortedForDisplay( + let loaded = FeatureDeviceSession.sortedForDisplay( try await manager.loadDeviceSessions() ) + guard operationGeneration == generation else { return } + sessions = loaded errorMessage = nil } catch { + guard operationGeneration == generation else { return } errorMessage = DeviceManagementErrorCopy.message(for: error) } } @MainActor private func revoke(_ session: FeatureDeviceSession) async { + operationGeneration &+= 1 + let generation = operationGeneration + isLoading = false isRevoking = true defer { - isRevoking = false - revokeTarget = nil + if operationGeneration == generation { + isRevoking = false + revokeTarget = nil + } } do { try await manager.revokeDeviceSession(id: session.id) + guard operationGeneration == generation else { return } sessions.removeAll { $0.id == session.id } errorMessage = nil } catch { + guard operationGeneration == generation else { return } errorMessage = DeviceManagementErrorCopy.message(for: error) } } @MainActor private func revokeOthers() async { + operationGeneration &+= 1 + let generation = operationGeneration + isLoading = false isRevoking = true - defer { isRevoking = false } + defer { + if operationGeneration == generation { isRevoking = false } + } do { try await manager.revokeOtherDeviceSessions() + guard operationGeneration == generation else { return } sessions.removeAll { !$0.isCurrent } errorMessage = nil } catch { + guard operationGeneration == generation else { return } errorMessage = DeviceManagementErrorCopy.message(for: error) } }