diff --git a/.changeset/preserve-sign-in-keyboard.md b/.changeset/preserve-sign-in-keyboard.md
new file mode 100644
index 00000000000..40c68d9c051
--- /dev/null
+++ b/.changeset/preserve-sign-in-keyboard.md
@@ -0,0 +1,5 @@
+---
+'@clerk/ui': patch
+---
+
+Fix the mobile keyboard closing during sign-in submission before the verification code input appears.
diff --git a/packages/ui/src/components/SignIn/SignInAlternativePhoneCodePhoneNumberCard.tsx b/packages/ui/src/components/SignIn/SignInAlternativePhoneCodePhoneNumberCard.tsx
index 5c784ba612b..2eacea89bc6 100644
--- a/packages/ui/src/components/SignIn/SignInAlternativePhoneCodePhoneNumberCard.tsx
+++ b/packages/ui/src/components/SignIn/SignInAlternativePhoneCodePhoneNumberCard.tsx
@@ -66,6 +66,7 @@ export const SignInAlternativePhoneCodePhoneNumberCard = (props: SignUpAlternati
>
diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx
index 812a1691107..dc771ae64eb 100644
--- a/packages/ui/src/components/SignIn/SignInStart.tsx
+++ b/packages/ui/src/components/SignIn/SignInStart.tsx
@@ -646,6 +646,7 @@ function SignInStartInternal(): JSX.Element {
{standardFormAttributes.length ? (
diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx
index a68ae94f56e..2f6a7b26e42 100644
--- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx
+++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx
@@ -2,17 +2,20 @@ import { ClerkAPIResponseError, ClerkWebAuthnError } from '@clerk/shared/error';
import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants';
import { OAUTH_PROVIDERS } from '@clerk/shared/oauth';
import type { SignInResource } from '@clerk/shared/types';
+import { createDeferredPromise } from '@clerk/shared/utils';
import { waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { simulateCaptchaInteractive, simulateCaptchaResolved } from '@/test/captcha';
import { bindCreateFixtures } from '@/test/create-fixtures';
-import { fireEvent, mockWebAuthn, render, screen } from '@/test/utils';
+import { act, fireEvent, mockWebAuthn, render, screen } from '@/test/utils';
import { CardStateProvider } from '@/ui/elements/contexts';
+import { Route, Switch, VirtualRouter } from '@/ui/router';
import { OptionsProvider } from '../../../contexts';
import { AppearanceProvider } from '../../../customizables';
import { SIGN_IN_RESET_PASSWORD_INTENT_PARAM } from '../shared';
+import { SignInFactorOne } from '../SignInFactorOne';
import { SignInStart } from '../SignInStart';
const { createFixtures } = bindCreateFixtures('SignIn');
@@ -66,6 +69,162 @@ describe('SignInStart', () => {
screen.getAllByText(/sign in to .*/i);
});
+ describe('identifier focus during submission', () => {
+ it('keeps the phone input focused and rejects edits while the request is pending', async () => {
+ const { wrapper, fixtures, props } = await createFixtures(f => {
+ f.withPhoneNumber();
+ f.withEmailAddress();
+ });
+ props.setProps({ initialValues: { phoneNumber: '+306911111111' } });
+ const request = createDeferredPromise();
+ fixtures.signIn.create.mockReturnValueOnce(request.promise);
+ const { userEvent } = render(, { wrapper });
+ const input = screen.getByRole('textbox', { name: /phone number/i });
+
+ await userEvent.click(input);
+ await userEvent.click(screen.getByText('Continue'));
+
+ expect(input).not.toBeDisabled();
+ expect(input).not.toHaveAttribute('readonly');
+ expect(input).toHaveFocus();
+ expect(input).toHaveAttribute('aria-disabled', 'true');
+ expect(screen.getByText('Continue').closest('button')).toBeDisabled();
+ expect(screen.getByRole('button', { name: /gr/i })).toBeDisabled();
+ await userEvent.keyboard('9{Backspace}');
+ await userEvent.paste('+14155552671');
+ fireEvent.change(input, { target: { value: '+14155552671' } });
+ expect(fireEvent.cut(input)).toBe(false);
+ await userEvent.keyboard('{Enter}');
+
+ expect(input).toHaveValue('691 1111111');
+ expect(fixtures.signIn.create).toHaveBeenCalledExactlyOnceWith({ identifier: '+306911111111' });
+ await act(async () => {
+ request.resolve({ status: 'needs_first_factor' });
+ await request.promise;
+ });
+ });
+
+ it('ignores duplicate submissions before a render and allows editing after an error', async () => {
+ const { wrapper, fixtures, props } = await createFixtures(f => f.withPhoneNumber());
+ props.setProps({ initialValues: { phoneNumber: '+306911111111' } });
+ const request = createDeferredPromise();
+ fixtures.signIn.create.mockReturnValueOnce(request.promise);
+ const { userEvent, container } = render(, { wrapper });
+ const input = screen.getByRole('textbox', { name: /phone number/i });
+ const form = container.querySelector('form');
+ await userEvent.click(input);
+
+ act(() => {
+ form?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
+ form?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
+ });
+
+ expect(fixtures.signIn.create).toHaveBeenCalledTimes(1);
+ expect(input).toHaveFocus();
+ await act(async () => {
+ request.reject(
+ new ClerkAPIResponseError('Error', {
+ data: [
+ { code: 'form_identifier_not_found', message: 'No account found', meta: { param_name: 'identifier' } },
+ ],
+ status: 422,
+ }),
+ );
+ await request.promise.catch(() => undefined);
+ });
+
+ expect(input).not.toBeDisabled();
+ expect(input).toHaveFocus();
+ expect(screen.getByText('Continue').closest('button')).not.toBeDisabled();
+ await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'true'));
+ fireEvent.change(input, { target: { value: '691 2222222' } });
+ fixtures.signIn.create.mockResolvedValueOnce({ status: 'needs_first_factor' } as SignInResource);
+ await userEvent.keyboard('{Enter}');
+ expect(fixtures.signIn.create).toHaveBeenLastCalledWith({ identifier: '+306912222222' });
+ });
+
+ it.each([
+ { submitWith: 'click', autoFocus: true },
+ { submitWith: 'Enter', autoFocus: true },
+ { submitWith: 'click', autoFocus: false },
+ ])('keeps OTP autofocus=$autoFocus after submitting with $submitWith', async options => {
+ const { submitWith, autoFocus } = options;
+ const { wrapper, fixtures, props } = await createFixtures(f => {
+ f.withPhoneNumber();
+ f.startSignInWithPhoneNumber({ supportPhoneCode: true, supportPassword: false });
+ });
+ props.setProps({ initialValues: { phoneNumber: '+306911111111' } });
+ fixtures.signIn.firstFactorVerification.status = 'unverified';
+ fixtures.signIn.firstFactorVerification.strategy = 'phone_code';
+ const request = createDeferredPromise();
+ fixtures.signIn.create.mockReturnValueOnce(request.promise);
+ const { userEvent } = render(
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ { wrapper },
+ );
+ const input = screen.getByRole('textbox', { name: /phone number/i });
+ await userEvent.click(input);
+ if (submitWith === 'click') {
+ await userEvent.click(screen.getByText('Continue'));
+ } else {
+ await userEvent.keyboard('{Enter}');
+ }
+
+ expect(input).not.toBeDisabled();
+ expect(input).toHaveFocus();
+ await act(async () => {
+ request.resolve(fixtures.signIn);
+ await request.promise;
+ });
+
+ expect(input).not.toBeInTheDocument();
+ const otp = screen.getByLabelText('Enter verification code');
+ expect(otp).toHaveAttribute('autocomplete', 'one-time-code');
+ if (autoFocus) {
+ expect(otp).toHaveFocus();
+ } else {
+ expect(otp).not.toHaveFocus();
+ await userEvent.click(otp);
+ }
+ await userEvent.keyboard('1');
+ expect(otp).toHaveValue('1');
+ });
+
+ it('does not restore identifier focus when the user moves focus during the request', async () => {
+ const { wrapper, fixtures, props } = await createFixtures(f => f.withPhoneNumber());
+ props.setProps({ initialValues: { phoneNumber: '+306911111111' } });
+ const request = createDeferredPromise();
+ fixtures.signIn.create.mockReturnValueOnce(request.promise);
+ const { userEvent } = render(, { wrapper });
+ const input = screen.getByRole('textbox', { name: /phone number/i });
+ await userEvent.click(input);
+ await userEvent.keyboard('{Enter}');
+ act(() => input.blur());
+
+ expect(input).not.toHaveFocus();
+ expect(input).toBeDisabled();
+ await act(async () => {
+ request.reject(new ClerkAPIResponseError('Error', { data: [], status: 422 }));
+ await request.promise.catch(() => undefined);
+ });
+ expect(input).not.toHaveFocus();
+ expect(input).not.toBeDisabled();
+ });
+ });
+
describe('Login Methods', () => {
it('enables login with email address', async () => {
const { wrapper } = await createFixtures(f => {
diff --git a/packages/ui/src/elements/FieldControl.tsx b/packages/ui/src/elements/FieldControl.tsx
index d2f7444ceae..408b12db978 100644
--- a/packages/ui/src/elements/FieldControl.tsx
+++ b/packages/ui/src/elements/FieldControl.tsx
@@ -30,18 +30,19 @@ import { PhoneInput } from './PhoneInput';
import { RadioItem, RadioLabel } from './RadioGroup';
type FormControlProps = Omit, 'label' | 'placeholder' | 'disabled' | 'required'> &
- ReturnType>['props'];
+ ReturnType>['props'] & { preserveFocus?: boolean };
const Root = (props: PropsWithChildren) => {
const card = useCardState();
const { autoFocus: optionAutoFocus } = useAppearance().parsedOptions;
- const { children, isDisabled: isDisabledProp, ...restProps } = props;
+ const { children, isDisabled: isDisabledProp, preserveFocus, ...restProps } = props;
const isDisabled = isDisabledProp || card.isLoading;
const ctxProps = {
...restProps,
isDisabled,
+ preserveFocus: preserveFocus && !isDisabledProp && card.isLoading && restProps.isFocused,
autoFocus: optionAutoFocus && restProps.autoFocus,
};
diff --git a/packages/ui/src/elements/Form.tsx b/packages/ui/src/elements/Form.tsx
index 613d28f2d38..fc437ca97b6 100644
--- a/packages/ui/src/elements/Form.tsx
+++ b/packages/ui/src/elements/Form.tsx
@@ -1,7 +1,7 @@
import { createContextAndHook } from '@clerk/shared/react';
import type { FieldId } from '@clerk/shared/types';
import type { PropsWithChildren } from 'react';
-import React, { forwardRef, useState } from 'react';
+import React, { forwardRef, useRef, useState } from 'react';
import type { LocalizationKey } from '../customizables';
import { Button, Col, descriptors, Flex, Form as FormPrim, localizationKeys } from '../customizables';
@@ -13,31 +13,36 @@ import type { OTPInputProps } from './CodeControl';
import { useCardState } from './contexts';
import { Field } from './FieldControl';
-const [FormState, useFormState] = createContextAndHook<{
+const [FormState, useFormState, useOptionalFormState] = createContextAndHook<{
isLoading: boolean;
isDisabled: boolean;
submittedWithEnter: boolean;
+ preserveFocusOnSubmit?: FieldId;
}>('FormState');
-type FormProps = PropsOfComponent;
+type FormProps = PropsOfComponent & { preserveFocusOnSubmit?: FieldId };
const FormRoot = (props: FormProps): JSX.Element => {
+ const { preserveFocusOnSubmit, ...formProps } = props;
const card = useCardState();
const status = useLoadingStatus();
const [submittedWithEnter, setSubmittedWithEnter] = useState(false);
+ const isSubmitting = useRef(false);
const onSubmit: React.FormEventHandler = async e => {
e.preventDefault();
e.stopPropagation();
- if (!props.onSubmit) {
+ if (!props.onSubmit || isSubmitting.current || card.isLoading) {
return;
}
try {
+ isSubmitting.current = true;
card.setLoading();
status.setLoading();
setSubmittedWithEnter(true);
await props.onSubmit(e);
} finally {
+ isSubmitting.current = false;
card.setIdle();
status.setIdle();
}
@@ -45,16 +50,21 @@ const FormRoot = (props: FormProps): JSX.Element => {
const value = React.useMemo(() => {
return {
- value: { isLoading: status.isLoading, isDisabled: card.isLoading || status.isLoading, submittedWithEnter },
+ value: {
+ isLoading: status.isLoading,
+ isDisabled: card.isLoading || status.isLoading,
+ submittedWithEnter,
+ preserveFocusOnSubmit,
+ },
};
- }, [card.isLoading, status.isLoading, submittedWithEnter]);
+ }, [card.isLoading, status.isLoading, submittedWithEnter, preserveFocusOnSubmit]);
return (
{/*
@@ -76,7 +86,7 @@ const FormRoot = (props: FormProps): JSX.Element => {
};
const FormSubmit = (props: PropsOfComponent) => {
- const { isLoading, isDisabled } = useFormState();
+ const { isLoading, isDisabled, preserveFocusOnSubmit } = useFormState();
return (