Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/preserve-sign-in-keyboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

Fix the mobile keyboard closing during sign-in submission before the verification code input appears.
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const SignInAlternativePhoneCodePhoneNumberCard = (props: SignUpAlternati
>
<Form.Root
onSubmit={handleSubmit}
preserveFocusOnSubmit='identifier'
gap={8}
>
<Col gap={6}>
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,7 @@ function SignInStartInternal(): JSX.Element {
{standardFormAttributes.length ? (
<Form.Root
onSubmit={handleFirstPartySubmit}
preserveFocusOnSubmit='identifier'
gap={8}
>
<Col gap={6}>
Expand Down
161 changes: 160 additions & 1 deletion packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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(<SignInStart />, { 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(<SignInStart />, { 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(
<AppearanceProvider appearance={{ options: { autoFocus } }}>
<VirtualRouter startPath='/sign-in'>
<Route path='sign-in'>
<Switch>
<Route path='factor-one'>
<SignInFactorOne />
</Route>
<Route index>
<SignInStart />
</Route>
</Switch>
</Route>
</VirtualRouter>
</AppearanceProvider>,
{ 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(<SignInStart />, { 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 => {
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/elements/FieldControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,19 @@ import { PhoneInput } from './PhoneInput';
import { RadioItem, RadioLabel } from './RadioGroup';

type FormControlProps = Omit<PropsOfComponent<typeof Input>, 'label' | 'placeholder' | 'disabled' | 'required'> &
ReturnType<typeof useFormControlUtil<FieldId>>['props'];
ReturnType<typeof useFormControlUtil<FieldId>>['props'] & { preserveFocus?: boolean };

const Root = (props: PropsWithChildren<FormControlProps>) => {
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,
};

Expand Down
46 changes: 37 additions & 9 deletions packages/ui/src/elements/Form.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,48 +13,58 @@ 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<typeof FormPrim>;
type FormProps = PropsOfComponent<typeof FormPrim> & { 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<HTMLFormElement> = 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();
}
};

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 (
<FormState.Provider value={value}>
<FormPrim
elementDescriptor={descriptors.form}
gap={6}
{...props}
{...formProps}
onSubmit={onSubmit}
>
{/*
Expand All @@ -76,7 +86,7 @@ const FormRoot = (props: FormProps): JSX.Element => {
};

const FormSubmit = (props: PropsOfComponent<typeof Button>) => {
const { isLoading, isDisabled } = useFormState();
const { isLoading, isDisabled, preserveFocusOnSubmit } = useFormState();
return (
<Button
elementDescriptor={descriptors.formButtonPrimary}
Expand All @@ -86,6 +96,20 @@ const FormSubmit = (props: PropsOfComponent<typeof Button>) => {
isDisabled={isDisabled}
type='submit'
{...props}
onMouseDown={e => {
props.onMouseDown?.(e);
if (
!e.defaultPrevented &&
e.button === 0 &&
preserveFocusOnSubmit &&
document.activeElement instanceof HTMLInputElement &&
document.activeElement.name === preserveFocusOnSubmit &&
e.currentTarget.form &&
document.activeElement.form === e.currentTarget.form
) {
e.preventDefault();
}
}}
localizationKey={props.localizationKey || localizationKeys('formButtonPrimary')}
/>
);
Expand Down Expand Up @@ -129,10 +153,14 @@ type CommonInputProps = CommonFieldRootProps & {
};

const CommonInputWrapper = (props: PropsWithChildren<CommonInputProps>) => {
const form = useOptionalFormState();
const { isOptional, isLastAuthenticationStrategy, icon, actionLabel, children, onActionClicked, ...fieldProps } =
props;
return (
<Field.Root {...fieldProps}>
<Field.Root
preserveFocus={form?.preserveFocusOnSubmit === fieldProps.id}
{...fieldProps}
>
<Col
elementDescriptor={descriptors.formField}
elementId={descriptors.formField.setId(fieldProps.id)}
Expand Down
Loading
Loading