From 09ecec43065414e4f422dc53abff55564ee57a2f Mon Sep 17 00:00:00 2001 From: Mauro Romito Date: Thu, 15 Oct 2026 11:28:04 +0200 Subject: [PATCH] Ignore repeated confirms on the server selection screen Tapping Continue and then the keyboard's Done key (or double tapping Continue) started two server configurations. With a password-only server both asked the flow to show the login screen, and the second request hit the authentication state machine's unexpected transition fatalError. Keep the running confirmation task and ignore further confirms until it finishes, so the flow continues once. The guard clears afterwards so confirming again after going back from the login screen still works. Fixes ELEMENT-X-IOS-XED --- .../ServerSelectionScreenViewModel.swift | 16 ++++++---- .../ServerSelectionScreenViewModelTests.swift | 30 +++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenViewModel.swift b/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenViewModel.swift index 6f6d8c558..4529b61cf 100644 --- a/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenViewModel.swift +++ b/ElementX/Sources/Screens/Authentication/ServerSelectionScreen/ServerSelectionScreenViewModel.swift @@ -20,6 +20,8 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server /// Debounces autocompletion from user input private var debouncedAutocompleteTask: Task? + /// The confirmation in progress, used to ignore repeated confirms (e.g. Continue then keyboard Done). + private var confirmTask: Task? private var actionsSubject: PassthroughSubject = .init() @@ -65,11 +67,15 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server guard state.textField != textField else { return } // stop the infinite loop state.textField = textField case .confirm: - switch state.mode { - case .userInput: - Task { await configureHomeserver() } - case .picker: - Task { await pickServer() } + guard confirmTask == nil else { return } + confirmTask = Task { + switch state.mode { + case .userInput: + await configureHomeserver() + case .picker: + await pickServer() + } + confirmTask = nil } case .clearFooterError: clearFooterError() diff --git a/UnitTests/Sources/ServerSelectionScreenViewModelTests.swift b/UnitTests/Sources/ServerSelectionScreenViewModelTests.swift index cbae0e1bd..51f408460 100644 --- a/UnitTests/Sources/ServerSelectionScreenViewModelTests.swift +++ b/UnitTests/Sources/ServerSelectionScreenViewModelTests.swift @@ -210,6 +210,36 @@ struct ServerSelectionScreenViewModelTests { #expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 2) } + @Test + mutating func userInputConfirmTwiceContinuesOnce() async throws { + // Given a view model for login with a server that doesn't support OAuth. + try setup(authenticationFlow: .login) + context.serverNameOrBaseURL = Self.passwordOnlyServer + + // When confirming a second time before the first confirmation has finished (e.g. Continue then keyboard Done). + var continueCount = 0 + let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1), message: "The flow should only continue once.") { action in + guard action.isContinueWithPassword else { return false } + continueCount += 1 + return continueCount > 1 + } + context.send(viewAction: .confirm) + context.send(viewAction: .confirm) + try await deferredFailure.fulfill() + + // Then the server should only be configured once. + #expect(continueCount == 1) + #expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1) + + // And when confirming again once finished (e.g. after navigating back from the login screen). + let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword } + context.send(viewAction: .confirm) + try await deferred.fulfill() + + // Then the flow should continue again. + #expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 2) + } + // MARK: - Picker mode @Test