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 (