From e6a2ccfaebf718af3ba8bbe65c4a1b71111e486e Mon Sep 17 00:00:00 2001 From: Jacob Hurwitz Date: Fri, 15 May 2026 23:52:56 -0700 Subject: [PATCH] Fix "A request is already pending" error when signing in with passkey The conditional mediation (autofill) request started on LoginScreen mount was still in flight when the user clicked "Sign in with passkey", causing the browser to reject the second request and the passkey picker to never appear. This is intermittent: it depends on whether the autofill's options fetch completes and registers navigator.credentials.get({mediation: "conditional"}) before the user clicks the button. Fix by storing an AbortController for the autofill request and aborting it before starting the explicit one. Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 15 +++++++++++++++ src/components/LoginScreen.tsx | 4 ++++ 2 files changed, 19 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index 906180b..5ae2f94 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -850,6 +850,14 @@ export default function App(props: AppProps) { navigate("/settings"); } + // Abort controller for the in-flight conditional mediation request, if any. + let passkeyAutofillAbortController: AbortController | null = null; + + function abortPasskeyAutofill() { + passkeyAutofillAbortController?.abort(); + passkeyAutofillAbortController = null; + } + // Try zero-click passkey authentication async function tryPasskeyAuth(): Promise { const startedAt = logPasskeyStart("autofill"); @@ -895,6 +903,7 @@ export default function App(props: AppProps) { }); // Try to get credential with conditional mediation + passkeyAutofillAbortController = new AbortController(); const credential = (await navigator.credentials.get({ publicKey: { challenge: base64UrlDecode(options.challenge).buffer as ArrayBuffer, @@ -904,7 +913,9 @@ export default function App(props: AppProps) { allowCredentials: [], }, mediation: "conditional", + signal: passkeyAutofillAbortController.signal, })) as PublicKeyCredential | null; + passkeyAutofillAbortController = null; if (!credential) { logPasskeyEvent("autofill", "credential.null"); @@ -948,6 +959,9 @@ export default function App(props: AppProps) { } return true; } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") { + return false; + } logPasskeyError("autofill", startedAt, err); return false; } @@ -1749,6 +1763,7 @@ export default function App(props: AppProps) { } await tryPasskeyAuth(); }} + onAbortAutofill={abortPasskeyAutofill} onAuthenticated={handleNativeAuthComplete} onChooseUsername={() => setAuthStep("choose_username")} onEmailLogin={() => setAuthStep("email_login")} diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx index ab512c0..2712233 100644 --- a/src/components/LoginScreen.tsx +++ b/src/components/LoginScreen.tsx @@ -19,6 +19,7 @@ import "./EmailLoginScreen.css"; type LoginScreenProps = { onTryPasskey: () => Promise; + onAbortAutofill: () => void; onAuthenticated: (user: User, needsPasskeySetup: boolean) => Promise; onChooseUsername: () => void; onEmailLogin: () => void; @@ -270,6 +271,9 @@ export function LoginScreen(props: LoginScreenProps) { } async function handlePasskeyLogin() { + // Abort any in-flight conditional mediation request before starting an explicit one. + // The browser allows only one pending WebAuthn request at a time. + props.onAbortAutofill(); const startedAt = logPasskeyStart("login"); if (!Capacitor.isNativePlatform() && !window.PublicKeyCredential) { setPasskeyError("Passkeys are not supported on this device");