From 495ce18004215a5065008d17a93c9db1f0c3959f Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Sun, 29 Mar 2026 14:51:54 +0300 Subject: [PATCH 1/4] feat(clerk-js,shared,ui): Add phoneNumberCountryCode and preferredSignInIdentifier Adds two new features to the SignIn component: - `phoneNumberCountryCode` (in `initialValues`): Preselects the country in the phone dropdown using an ISO 3166 alpha-2 code, without pre-filling the phone number. Precedence: parsed phone > defaultCountryIso > geo-IP > 'us'. - `preferredSignInIdentifier` (in `appearance.options`): Selects which identifier type (email, phone, username) shows first without pre-filling a value. Falls back to default ordering when the identifier is not enabled. --- .../phone-country-preferred-identifier.md | 6 ++ .../shared/src/internal/clerk-js/constants.ts | 2 +- packages/shared/src/types/clerk.ts | 1 + ...nInAlternativePhoneCodePhoneNumberCard.tsx | 5 +- .../ui/src/components/SignIn/SignInStart.tsx | 55 ++++++++++++++--- .../SignIn/__tests__/SignInStart.test.tsx | 59 +++++++++++++++++++ .../ui/src/customizables/parseAppearance.ts | 4 +- packages/ui/src/elements/FieldControl.tsx | 4 +- packages/ui/src/elements/Form.tsx | 8 ++- .../__tests__/useFormattedPhoneNumber.test.ts | 58 ++++++++++++++++++ packages/ui/src/elements/PhoneInput/index.tsx | 16 ++++- .../PhoneInput/useFormattedPhoneNumber.ts | 21 +++++-- packages/ui/src/internal/appearance.ts | 8 +++ packages/ui/src/test/create-fixtures.tsx | 5 +- 14 files changed, 228 insertions(+), 24 deletions(-) create mode 100644 .changeset/phone-country-preferred-identifier.md diff --git a/.changeset/phone-country-preferred-identifier.md b/.changeset/phone-country-preferred-identifier.md new file mode 100644 index 00000000000..b3e96a09bca --- /dev/null +++ b/.changeset/phone-country-preferred-identifier.md @@ -0,0 +1,6 @@ +--- +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Add `phoneNumberCountryCode` to `SignIn` component's `initialValues` prop for preselecting the phone dropdown country via ISO 3166 alpha-2 code. Add `preferredSignInIdentifier` to `appearance.options` for selecting which identifier type (email, phone, username) shows first without pre-filling a value. diff --git a/packages/shared/src/internal/clerk-js/constants.ts b/packages/shared/src/internal/clerk-js/constants.ts index f81693798e1..6c503f12400 100644 --- a/packages/shared/src/internal/clerk-js/constants.ts +++ b/packages/shared/src/internal/clerk-js/constants.ts @@ -50,7 +50,7 @@ export const ERROR_CODES = { USER_DEACTIVATED: 'user_deactivated', } as const; -export const SIGN_IN_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username']; +export const SIGN_IN_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username', 'phone_number_country_code']; export const SIGN_UP_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username', 'first_name', 'last_name']; export const DEBOUNCE_MS = 350; diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 670a1a21ba0..9bb4452ec7b 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1366,6 +1366,7 @@ export type SignInInitialValues = { emailAddress?: string; phoneNumber?: string; username?: string; + phoneNumberCountryCode?: string; }; export type SignUpInitialValues = { diff --git a/packages/ui/src/components/SignIn/SignInAlternativePhoneCodePhoneNumberCard.tsx b/packages/ui/src/components/SignIn/SignInAlternativePhoneCodePhoneNumberCard.tsx index 5c784ba612b..09e43d42fb8 100644 --- a/packages/ui/src/components/SignIn/SignInAlternativePhoneCodePhoneNumberCard.tsx +++ b/packages/ui/src/components/SignIn/SignInAlternativePhoneCodePhoneNumberCard.tsx @@ -1,4 +1,5 @@ import type { PhoneCodeChannelData } from '@clerk/shared/types'; +import type { CountryIso } from '@/ui/elements/PhoneInput/countryCodeData'; import { Card } from '@/ui/elements/Card'; import { useCardState } from '@/ui/elements/contexts'; @@ -16,10 +17,11 @@ type SignUpAlternativePhoneCodePhoneNumberCardProps = { phoneNumberFormState: FormControlState; onUseAnotherMethod: () => void; phoneCodeProvider: PhoneCodeChannelData; + defaultCountryIso?: CountryIso; }; export const SignInAlternativePhoneCodePhoneNumberCard = (props: SignUpAlternativePhoneCodePhoneNumberCardProps) => { - const { handleSubmit, phoneNumberFormState, onUseAnotherMethod, phoneCodeProvider } = props; + const { handleSubmit, phoneNumberFormState, onUseAnotherMethod, phoneCodeProvider, defaultCountryIso } = props; const { providerToDisplayData, strategyToDisplayData } = useEnabledThirdPartyProviders(); const provider = phoneCodeProvider.name; const channel = phoneCodeProvider.channel; @@ -72,6 +74,7 @@ export const SignInAlternativePhoneCodePhoneNumberCard = (props: SignUpAlternati ( () => groupIdentifiers(userSettings.enabledFirstFactorIdentifiers), @@ -103,13 +105,46 @@ function SignInStartInternal(): JSX.Element { const authenticateWithPasskey = useHandleAuthenticateWithPasskey(onSecondFactor); const isWebSupported = isWebAuthnSupported(); - const onlyPhoneNumberInitialValueExists = - !!ctx.initialValues?.phoneNumber && !(ctx.initialValues.emailAddress || ctx.initialValues.username); - const shouldStartWithPhoneNumberIdentifier = - onlyPhoneNumberInitialValueExists && identifierAttributes.includes('phone_number'); - const [identifierAttribute, setIdentifierAttribute] = useState( - shouldStartWithPhoneNumberIdentifier ? 'phone_number' : identifierAttributes[0] || '', - ); + const resolveInitialIdentifier = (): SignInStartIdentifier => { + const iv = ctx.initialValues; + + const mapToIdentifierAttribute = (key: string): SignInStartIdentifier | undefined => { + if (key === 'phoneNumber') { + return identifierAttributes.includes('phone_number') ? 'phone_number' : undefined; + } + if (key === 'emailAddress') { + if (identifierAttributes.includes('email_address')) return 'email_address'; + if (identifierAttributes.includes('email_address_username')) return 'email_address_username'; + return undefined; + } + if (key === 'username') { + if (identifierAttributes.includes('email_address_username')) return 'email_address_username'; + if (identifierAttributes.includes('username')) return 'username'; + return undefined; + } + return undefined; + }; + + const filledValues = [ + iv?.emailAddress && 'emailAddress', + iv?.phoneNumber && 'phoneNumber', + iv?.username && 'username', + ].filter(Boolean) as string[]; + + if (filledValues.length === 1) { + const mapped = mapToIdentifierAttribute(filledValues[0]); + if (mapped) return mapped; + } + + if (parsedOptions.preferredSignInIdentifier) { + const mapped = mapToIdentifierAttribute(parsedOptions.preferredSignInIdentifier); + if (mapped) return mapped; + } + + return identifierAttributes[0] || ''; + }; + + const [identifierAttribute, setIdentifierAttribute] = useState(resolveInitialIdentifier); const [hasSwitchedByAutofill, setHasSwitchedByAutofill] = useState(false); const organizationTicket = getClerkQueryParam('__clerk_ticket') || ''; @@ -596,6 +631,9 @@ function SignInStartInternal(): JSX.Element { actionLabel={nextIdentifier?.action} onActionClicked={switchToNextIdentifier} {...identifierFieldProps} + defaultCountryIso={ + ctx.initialValues?.phoneNumberCountryCode?.toLowerCase() as CountryIso | undefined + } autoFocus={shouldAutofocus} autoComplete={isWebAuthnAutofillSupported ? 'webauthn' : undefined} isLastAuthenticationStrategy={isIdentifierLastAuthenticationStrategy} @@ -650,6 +688,7 @@ function SignInStartInternal(): JSX.Element { phoneNumberFormState={phoneIdentifierField} onUseAnotherMethod={onAlternativePhoneCodeUseAnotherMethod} phoneCodeProvider={alternativePhoneCodeProvider} + defaultCountryIso={ctx.initialValues?.phoneNumberCountryCode?.toLowerCase() as CountryIso | undefined} /> )} diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index 7b14917cbe5..8e6612b18df 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -479,6 +479,65 @@ describe('SignInStart', () => { }); }); + describe('preferredSignInIdentifier (appearance option)', () => { + it('selects phone_number tab when set to phoneNumber', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPhoneNumber(); + }); + props.setProps({ appearance: { options: { preferredSignInIdentifier: 'phoneNumber' } } }); + + render(, { wrapper }); + screen.getByText(/phone number/i); + }); + + it('selects email_address tab when set to emailAddress', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPhoneNumber(); + }); + props.setProps({ appearance: { options: { preferredSignInIdentifier: 'emailAddress' } } }); + + render(, { wrapper }); + screen.getByText(/email address/i); + }); + + it('is ignored when identifier is not enabled', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withEmailAddress(); + }); + props.setProps({ appearance: { options: { preferredSignInIdentifier: 'phoneNumber' } } }); + + render(, { wrapper }); + screen.getByText(/email address/i); + }); + + it('single filled initialValues value takes precedence', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPhoneNumber(); + }); + props.setProps({ + initialValues: { phoneNumber: '+306911111111' }, + appearance: { options: { preferredSignInIdentifier: 'emailAddress' } }, + }); + + render(, { wrapper }); + screen.getByDisplayValue(/691 1111111/i); + }); + + it('username maps to email_address_username when both enabled', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withUsername(); + }); + props.setProps({ appearance: { options: { preferredSignInIdentifier: 'username' } } }); + + render(, { wrapper }); + screen.getByText(/email address or username/i); + }); + }); + describe('Submitting form via instant password autofill', () => { const ERROR_CODES = ['strategy_for_user_invalid', 'form_password_incorrect', 'form_password_pwned']; ERROR_CODES.forEach(code => { diff --git a/packages/ui/src/customizables/parseAppearance.ts b/packages/ui/src/customizables/parseAppearance.ts index a61b883191f..3bc3d0fe0a4 100644 --- a/packages/ui/src/customizables/parseAppearance.ts +++ b/packages/ui/src/customizables/parseAppearance.ts @@ -17,7 +17,8 @@ import { export type ParsedElements = Elements[]; export type ParsedInternalTheme = InternalTheme; -export type ParsedOptions = Required; +export type ParsedOptions = Required> & + Pick; export type ParsedCaptcha = Required; type PublicAppearanceTopLevelKey = Exclude< @@ -51,6 +52,7 @@ const defaultOptions: ParsedOptions = { shimmer: true, animations: true, unsafe_disableDevelopmentModeWarnings: false, + preferredSignInIdentifier: undefined, }; const defaultCaptchaOptions: ParsedCaptcha = { diff --git a/packages/ui/src/elements/FieldControl.tsx b/packages/ui/src/elements/FieldControl.tsx index 94995209229..2d21bc90010 100644 --- a/packages/ui/src/elements/FieldControl.tsx +++ b/packages/ui/src/elements/FieldControl.tsx @@ -25,6 +25,7 @@ import type { FormFeedbackProps } from './FormControl'; import { FormFeedback } from './FormControl'; import { InputGroup } from './InputGroup'; import { PasswordInput } from './PasswordInput'; +import type { CountryIso } from './PhoneInput/countryCodeData'; import { PhoneInput } from './PhoneInput'; import { RadioItem, RadioLabel } from './RadioGroup'; @@ -168,7 +169,7 @@ const FieldFeedback = (props: Pick((_, ref) => { +const PhoneInputElement = forwardRef((props, ref) => { const { t } = useLocalizations(); const formField = useFormField(); const { placeholder, ...inputProps } = sanitizeInputProps(formField); @@ -179,6 +180,7 @@ const PhoneInputElement = forwardRef((_, ref) => { elementDescriptor={descriptors.formFieldInput} elementId={descriptors.formFieldInput.setId(formField.fieldId)} {...inputProps} + defaultCountryIso={props.defaultCountryIso} feedbackType={formField.feedbackType} placeholder={t(placeholder)} /> diff --git a/packages/ui/src/elements/Form.tsx b/packages/ui/src/elements/Form.tsx index 933b09ff461..13ee06a2748 100644 --- a/packages/ui/src/elements/Form.tsx +++ b/packages/ui/src/elements/Form.tsx @@ -1,5 +1,6 @@ import { createContextAndHook } from '@clerk/shared/react'; import type { FieldId } from '@clerk/shared/types'; +import type { CountryIso } from './PhoneInput/countryCodeData'; import type { PropsWithChildren } from 'react'; import React, { forwardRef, useState } from 'react'; @@ -196,10 +197,11 @@ const PasswordInput = forwardRef((props, ref ); }); -const PhoneInput = (props: CommonInputProps) => { +const PhoneInput = (props: CommonInputProps & { defaultCountryIso?: CountryIso }) => { + const { defaultCountryIso, ...rest } = props; return ( - - + + ); }; diff --git a/packages/ui/src/elements/PhoneInput/__tests__/useFormattedPhoneNumber.test.ts b/packages/ui/src/elements/PhoneInput/__tests__/useFormattedPhoneNumber.test.ts index 9801aec8695..e95fb7f12a1 100644 --- a/packages/ui/src/elements/PhoneInput/__tests__/useFormattedPhoneNumber.test.ts +++ b/packages/ui/src/elements/PhoneInput/__tests__/useFormattedPhoneNumber.test.ts @@ -118,4 +118,62 @@ describe('useFormattedPhoneNumber', () => { unmount(); }); + + it('defaultCountryIso is used when no phone number and no locationBasedCountryIso', () => { + const { result } = renderHook(() => + useFormattedPhoneNumber({ + initPhoneWithCode: '', + defaultCountryIso: 'gr', + locationBasedCountryIso: undefined, + }), + ); + + expect(result.current.iso).toBe('gr'); + }); + + it('defaultCountryIso takes precedence over locationBasedCountryIso', () => { + const { result } = renderHook(() => + useFormattedPhoneNumber({ + initPhoneWithCode: '', + defaultCountryIso: 'de', + locationBasedCountryIso: 'fr', + }), + ); + + expect(result.current.iso).toBe('de'); + }); + + it('parsed phone number takes precedence over defaultCountryIso', () => { + const { result } = renderHook(() => + useFormattedPhoneNumber({ + initPhoneWithCode: '+71111111111', + defaultCountryIso: 'gr', + }), + ); + + expect(result.current.iso).toBe('ru'); + }); + + it('invalid defaultCountryIso falls back to locationBasedCountryIso', () => { + const { result } = renderHook(() => + useFormattedPhoneNumber({ + initPhoneWithCode: '', + defaultCountryIso: 'xx' as any, + locationBasedCountryIso: 'fr', + }), + ); + + expect(result.current.iso).toBe('fr'); + }); + + it('invalid defaultCountryIso with no locationBasedCountryIso falls back to us', () => { + const { result } = renderHook(() => + useFormattedPhoneNumber({ + initPhoneWithCode: '', + defaultCountryIso: 'zz' as any, + }), + ); + + expect(result.current.iso).toBe('us'); + }); }); diff --git a/packages/ui/src/elements/PhoneInput/index.tsx b/packages/ui/src/elements/PhoneInput/index.tsx index 7882e0623be..5c8f8412be0 100644 --- a/packages/ui/src/elements/PhoneInput/index.tsx +++ b/packages/ui/src/elements/PhoneInput/index.tsx @@ -23,15 +23,27 @@ const createSelectOption = (country: CountryEntry) => { const countryOptions = [...IsoToCountryMap.values()].map(createSelectOption); -type PhoneInputProps = PropsOfComponent & { locationBasedCountryIso?: CountryIso }; +type PhoneInputProps = PropsOfComponent & { + locationBasedCountryIso?: CountryIso; + defaultCountryIso?: CountryIso; +}; const PhoneInputBase = forwardRef((props, ref) => { - const { onChange: onChangeProp, value, locationBasedCountryIso, feedbackType, sx, ...rest } = props; + const { + onChange: onChangeProp, + value, + locationBasedCountryIso, + defaultCountryIso, + feedbackType, + sx, + ...rest + } = props; const phoneInputRef = useRef(null); const phoneInputBox = useRef(null); const { setNumber, setIso, setNumberAndIso, numberWithCode, iso, formattedNumber } = useFormattedPhoneNumber({ initPhoneWithCode: value as string, locationBasedCountryIso, + defaultCountryIso, }); const callOnChangeProp = () => { diff --git a/packages/ui/src/elements/PhoneInput/useFormattedPhoneNumber.ts b/packages/ui/src/elements/PhoneInput/useFormattedPhoneNumber.ts index 8fec8c52271..92f5bfb84b7 100644 --- a/packages/ui/src/elements/PhoneInput/useFormattedPhoneNumber.ts +++ b/packages/ui/src/elements/PhoneInput/useFormattedPhoneNumber.ts @@ -5,7 +5,11 @@ import { extractDigits, formatPhoneNumber, parsePhoneString } from '@/ui/utils/p import type { CountryIso } from './countryCodeData'; import { IsoToCountryMap } from './countryCodeData'; -type UseFormattedPhoneNumberProps = { initPhoneWithCode: string; locationBasedCountryIso?: CountryIso }; +type UseFormattedPhoneNumberProps = { + initPhoneWithCode: string; + locationBasedCountryIso?: CountryIso; + defaultCountryIso?: CountryIso; +}; const format = (str: string, iso: CountryIso) => { if (!str) { @@ -21,11 +25,16 @@ export const useFormattedPhoneNumber = (props: UseFormattedPhoneNumberProps) => return number; }); - const [iso, setIso] = React.useState( - parsePhoneString(props.initPhoneWithCode || '').number - ? parsePhoneString(props.initPhoneWithCode || '').iso - : props.locationBasedCountryIso || 'us', - ); + const [iso, setIso] = React.useState(() => { + const parsed = parsePhoneString(props.initPhoneWithCode || ''); + if (parsed.number) { + return parsed.iso; + } + if (props.defaultCountryIso && IsoToCountryMap.has(props.defaultCountryIso)) { + return props.defaultCountryIso; + } + return props.locationBasedCountryIso || 'us'; + }); React.useEffect(() => { setNumber(extractDigits(number)); diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts index cf4b8ac09d6..bdcf159ada7 100644 --- a/packages/ui/src/internal/appearance.ts +++ b/packages/ui/src/internal/appearance.ts @@ -963,6 +963,14 @@ export type Options = { * @default false */ unsafe_disableDevelopmentModeWarnings?: boolean; + + /** + * Controls which identifier type is shown first on the SignIn component + * when multiple identifiers are enabled. Does not prefill any value. + * + * @default undefined + */ + preferredSignInIdentifier?: 'emailAddress' | 'phoneNumber' | 'username'; }; export type CaptchaAppearanceOptions = { diff --git a/packages/ui/src/test/create-fixtures.tsx b/packages/ui/src/test/create-fixtures.tsx index 6824c98ec14..2e16fe5edc6 100644 --- a/packages/ui/src/test/create-fixtures.tsx +++ b/packages/ui/src/test/create-fixtures.tsx @@ -125,7 +125,10 @@ const unboundCreateFixtures = ( - + {contextWrappedChildren} From 38dc379c940f2455ff8705b171041488c5573253 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Sun, 29 Mar 2026 15:13:49 +0300 Subject: [PATCH 2/4] refactor(ui): Rename preferredSignInIdentifier to preferredIdentifier --- .changeset/phone-country-preferred-identifier.md | 2 +- packages/ui/src/components/SignIn/SignInStart.tsx | 4 ++-- .../components/SignIn/__tests__/SignInStart.test.tsx | 12 ++++++------ packages/ui/src/customizables/parseAppearance.ts | 5 ++--- packages/ui/src/internal/appearance.ts | 2 +- 5 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.changeset/phone-country-preferred-identifier.md b/.changeset/phone-country-preferred-identifier.md index b3e96a09bca..3dac0eb5935 100644 --- a/.changeset/phone-country-preferred-identifier.md +++ b/.changeset/phone-country-preferred-identifier.md @@ -3,4 +3,4 @@ '@clerk/ui': minor --- -Add `phoneNumberCountryCode` to `SignIn` component's `initialValues` prop for preselecting the phone dropdown country via ISO 3166 alpha-2 code. Add `preferredSignInIdentifier` to `appearance.options` for selecting which identifier type (email, phone, username) shows first without pre-filling a value. +Add `phoneNumberCountryCode` to `SignIn` component's `initialValues` prop for preselecting the phone dropdown country via ISO 3166 alpha-2 code. Add `preferredIdentifier` to `appearance.options` for selecting which identifier type (email, phone, username) shows first without pre-filling a value. diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index bd78e46f0b4..871f24d1b1f 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -136,8 +136,8 @@ function SignInStartInternal(): JSX.Element { if (mapped) return mapped; } - if (parsedOptions.preferredSignInIdentifier) { - const mapped = mapToIdentifierAttribute(parsedOptions.preferredSignInIdentifier); + if (parsedOptions.preferredIdentifier) { + const mapped = mapToIdentifierAttribute(parsedOptions.preferredIdentifier); if (mapped) return mapped; } diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index 8e6612b18df..bc3ea7dc4c2 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -479,13 +479,13 @@ describe('SignInStart', () => { }); }); - describe('preferredSignInIdentifier (appearance option)', () => { + describe('preferredIdentifier (appearance option)', () => { it('selects phone_number tab when set to phoneNumber', async () => { const { wrapper, props } = await createFixtures(f => { f.withEmailAddress(); f.withPhoneNumber(); }); - props.setProps({ appearance: { options: { preferredSignInIdentifier: 'phoneNumber' } } }); + props.setProps({ appearance: { options: { preferredIdentifier: 'phoneNumber' } } }); render(, { wrapper }); screen.getByText(/phone number/i); @@ -496,7 +496,7 @@ describe('SignInStart', () => { f.withEmailAddress(); f.withPhoneNumber(); }); - props.setProps({ appearance: { options: { preferredSignInIdentifier: 'emailAddress' } } }); + props.setProps({ appearance: { options: { preferredIdentifier: 'emailAddress' } } }); render(, { wrapper }); screen.getByText(/email address/i); @@ -506,7 +506,7 @@ describe('SignInStart', () => { const { wrapper, props } = await createFixtures(f => { f.withEmailAddress(); }); - props.setProps({ appearance: { options: { preferredSignInIdentifier: 'phoneNumber' } } }); + props.setProps({ appearance: { options: { preferredIdentifier: 'phoneNumber' } } }); render(, { wrapper }); screen.getByText(/email address/i); @@ -519,7 +519,7 @@ describe('SignInStart', () => { }); props.setProps({ initialValues: { phoneNumber: '+306911111111' }, - appearance: { options: { preferredSignInIdentifier: 'emailAddress' } }, + appearance: { options: { preferredIdentifier: 'emailAddress' } }, }); render(, { wrapper }); @@ -531,7 +531,7 @@ describe('SignInStart', () => { f.withEmailAddress(); f.withUsername(); }); - props.setProps({ appearance: { options: { preferredSignInIdentifier: 'username' } } }); + props.setProps({ appearance: { options: { preferredIdentifier: 'username' } } }); render(, { wrapper }); screen.getByText(/email address or username/i); diff --git a/packages/ui/src/customizables/parseAppearance.ts b/packages/ui/src/customizables/parseAppearance.ts index 3bc3d0fe0a4..cc194fc574b 100644 --- a/packages/ui/src/customizables/parseAppearance.ts +++ b/packages/ui/src/customizables/parseAppearance.ts @@ -17,8 +17,7 @@ import { export type ParsedElements = Elements[]; export type ParsedInternalTheme = InternalTheme; -export type ParsedOptions = Required> & - Pick; +export type ParsedOptions = Required> & Pick; export type ParsedCaptcha = Required; type PublicAppearanceTopLevelKey = Exclude< @@ -52,7 +51,7 @@ const defaultOptions: ParsedOptions = { shimmer: true, animations: true, unsafe_disableDevelopmentModeWarnings: false, - preferredSignInIdentifier: undefined, + preferredIdentifier: undefined, }; const defaultCaptchaOptions: ParsedCaptcha = { diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts index bdcf159ada7..b9e861062af 100644 --- a/packages/ui/src/internal/appearance.ts +++ b/packages/ui/src/internal/appearance.ts @@ -970,7 +970,7 @@ export type Options = { * * @default undefined */ - preferredSignInIdentifier?: 'emailAddress' | 'phoneNumber' | 'username'; + preferredIdentifier?: 'emailAddress' | 'phoneNumber' | 'username'; }; export type CaptchaAppearanceOptions = { From 3ecf92ddf4681513b38d873aad9e2c029a0098f4 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Sun, 29 Mar 2026 15:18:44 +0300 Subject: [PATCH 3/4] feat(ui): Wire preferredIdentifier into SignUp's getInitialActiveIdentifier When both email and phone are in the same tier (both enabled and both required, or both enabled and both optional), preferredIdentifier from appearance.options now breaks the tie instead of hardcoding email-first. --- .../src/components/SignUp/SignUpContinue.tsx | 5 +- .../ui/src/components/SignUp/SignUpStart.tsx | 27 +++++--- .../__tests__/signUpFormHelpers.test.ts | 67 +++++++++++++++++++ .../components/SignUp/signUpFormHelpers.ts | 20 ++++-- 4 files changed, 102 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/components/SignUp/SignUpContinue.tsx b/packages/ui/src/components/SignUp/SignUpContinue.tsx index 29ae18c8e31..365af7e1b08 100644 --- a/packages/ui/src/components/SignUp/SignUpContinue.tsx +++ b/packages/ui/src/components/SignUp/SignUpContinue.tsx @@ -12,7 +12,7 @@ import { buildRequest, useFormControl } from '@/ui/utils/useFormControl'; import { createUsernameError } from '@/ui/utils/usernameUtils'; import { SignInContext, useCoreSignUp, useEnvironment, useSignUpContext } from '../../contexts'; -import { descriptors, Flex, Flow, localizationKeys, useLocalizations } from '../../customizables'; +import { descriptors, Flex, Flow, localizationKeys, useAppearance, useLocalizations } from '../../customizables'; import { useRouter } from '../../router'; import { SignUpForm } from './SignUpForm'; import type { ActiveIdentifier } from './signUpFormHelpers'; @@ -44,8 +44,9 @@ function SignUpContinueInternal() { const isWithinSignInContext = !!React.useContext(SignInContext); const isCombinedFlow = !!(_isCombinedFlow && !!isWithinSignInContext); const isProgressiveSignUp = userSettings.signUp.progressive; + const { preferredIdentifier } = useAppearance().parsedOptions; const [activeCommIdentifierType, setActiveCommIdentifierType] = React.useState( - getInitialActiveIdentifier(attributes, userSettings.signUp.progressive), + getInitialActiveIdentifier(attributes, userSettings.signUp.progressive, undefined, preferredIdentifier), ); const ctx = useSignUpContext(); diff --git a/packages/ui/src/components/SignUp/SignUpStart.tsx b/packages/ui/src/components/SignUp/SignUpStart.tsx index 90059b9a2f5..7cdb7e669b8 100644 --- a/packages/ui/src/components/SignUp/SignUpStart.tsx +++ b/packages/ui/src/components/SignUp/SignUpStart.tsx @@ -37,7 +37,7 @@ function SignUpStartInternal(): JSX.Element { const clerk = useClerk(); const status = useLoadingStatus(); const signUp = useCoreSignUp(); - const { showOptionalFields } = useAppearance().parsedOptions; + const { showOptionalFields, preferredIdentifier } = useAppearance().parsedOptions; const { userSettings, authConfig } = useEnvironment(); const { navigate } = useRouter(); const { attributes } = userSettings; @@ -47,16 +47,21 @@ function SignUpStartInternal(): JSX.Element { const { afterSignUpUrl, signInUrl, unsafeMetadata, navigateOnSetActive } = ctx; const isCombinedFlow = !!(ctx.isCombinedFlow && !!isWithinSignInContext); const [activeCommIdentifierType, setActiveCommIdentifierType] = React.useState(() => - getInitialActiveIdentifier(attributes, userSettings.signUp.progressive, { - phoneNumber: ctx.initialValues?.phoneNumber === null ? undefined : ctx.initialValues?.phoneNumber, - emailAddress: ctx.initialValues?.emailAddress === null ? undefined : ctx.initialValues?.emailAddress, - ...(isCombinedFlow - ? { - emailAddress: signUp.emailAddress, - phoneNumber: signUp.phoneNumber, - } - : {}), - }), + getInitialActiveIdentifier( + attributes, + userSettings.signUp.progressive, + { + phoneNumber: ctx.initialValues?.phoneNumber === null ? undefined : ctx.initialValues?.phoneNumber, + emailAddress: ctx.initialValues?.emailAddress === null ? undefined : ctx.initialValues?.emailAddress, + ...(isCombinedFlow + ? { + emailAddress: signUp.emailAddress, + phoneNumber: signUp.phoneNumber, + } + : {}), + }, + preferredIdentifier, + ), ); const { t, locale } = useLocalizations(); const initialValues = ctx.initialValues || {}; diff --git a/packages/ui/src/components/SignUp/__tests__/signUpFormHelpers.test.ts b/packages/ui/src/components/SignUp/__tests__/signUpFormHelpers.test.ts index 5472b9b3fd9..36deba01eda 100644 --- a/packages/ui/src/components/SignUp/__tests__/signUpFormHelpers.test.ts +++ b/packages/ui/src/components/SignUp/__tests__/signUpFormHelpers.test.ts @@ -1047,4 +1047,71 @@ describe('getInitialActiveIdentifier()', () => { expect(getInitialActiveIdentifier(attributes, false)).toBe(null); }); }); + + describe('respects preferredIdentifier on ties', () => { + it('returns phoneNumber in email-or-phone when preferredIdentifier is phoneNumber', () => { + const attributes = { + email_address: createAttributeData('email_address', true, false, true), + phone_number: createAttributeData('phone_number', true, false, true), + }; + + expect(getInitialActiveIdentifier(attributes, false, undefined, 'phoneNumber')).toBe('phoneNumber'); + }); + + it('returns emailAddress in email-or-phone when preferredIdentifier is emailAddress', () => { + const attributes = { + email_address: createAttributeData('email_address', true, false, true), + phone_number: createAttributeData('phone_number', true, false, true), + }; + + expect(getInitialActiveIdentifier(attributes, false, undefined, 'emailAddress')).toBe('emailAddress'); + }); + + it('returns phoneNumber when both are required and preferredIdentifier is phoneNumber', () => { + const attributes = { + email_address: createAttributeData('email_address', true, true, true), + phone_number: createAttributeData('phone_number', true, true, true), + }; + + expect(getInitialActiveIdentifier(attributes, false, undefined, 'phoneNumber')).toBe('phoneNumber'); + }); + + it('defaults to emailAddress in email-or-phone when no preferredIdentifier', () => { + const attributes = { + email_address: createAttributeData('email_address', true, false, true), + phone_number: createAttributeData('phone_number', true, false, true), + }; + + expect(getInitialActiveIdentifier(attributes, false)).toBe('emailAddress'); + }); + + it('ignores preferredIdentifier username since SignUp only has email/phone', () => { + const attributes = { + email_address: createAttributeData('email_address', true, false, true), + phone_number: createAttributeData('phone_number', true, false, true), + }; + + expect(getInitialActiveIdentifier(attributes, false, undefined, 'username')).toBe('emailAddress'); + }); + + it('initialValues take precedence over preferredIdentifier', () => { + const attributes = { + email_address: createAttributeData('email_address', true, false, true), + phone_number: createAttributeData('phone_number', true, false, true), + }; + + expect(getInitialActiveIdentifier(attributes, false, { emailAddress: 'test@example.com' }, 'phoneNumber')).toBe( + 'emailAddress', + ); + }); + + it('returns phoneNumber in progressive signup email-or-phone with preferredIdentifier', () => { + const attributes = { + email_address: createAttributeData('email_address', true, false, true), + phone_number: createAttributeData('phone_number', true, false, true), + }; + + expect(getInitialActiveIdentifier(attributes, true, undefined, 'phoneNumber')).toBe('phoneNumber'); + }); + }); }); diff --git a/packages/ui/src/components/SignUp/signUpFormHelpers.ts b/packages/ui/src/components/SignUp/signUpFormHelpers.ts index c2fc0c9ce3f..d6cf131592d 100644 --- a/packages/ui/src/components/SignUp/signUpFormHelpers.ts +++ b/packages/ui/src/components/SignUp/signUpFormHelpers.ts @@ -107,6 +107,7 @@ export const getInitialActiveIdentifier = ( attributes: Partial, isProgressiveSignUp: boolean, initialValues?: { phoneNumber?: string | null; emailAddress?: string | null }, + preferredIdentifier?: 'emailAddress' | 'phoneNumber' | 'username', ): ActiveIdentifier => { if (initialValues?.emailAddress) { return 'emailAddress'; @@ -115,18 +116,29 @@ export const getInitialActiveIdentifier = ( return 'phoneNumber'; } + const preferred = + preferredIdentifier === 'emailAddress' || preferredIdentifier === 'phoneNumber' ? preferredIdentifier : undefined; + if (emailOrPhone(attributes, isProgressiveSignUp)) { - // If we are in the case of Email OR Phone, email takes priority - return 'emailAddress'; + return preferred ?? 'emailAddress'; } const { email_address, phone_number } = attributes; - if (email_address?.enabled && isProgressiveSignUp ? email_address.required : email_address?.used_for_first_factor) { + const emailMatches = + email_address?.enabled && (isProgressiveSignUp ? email_address.required : email_address?.used_for_first_factor); + const phoneMatches = + phone_number?.enabled && (isProgressiveSignUp ? phone_number.required : phone_number?.used_for_first_factor); + + if (emailMatches && phoneMatches) { + return preferred ?? 'emailAddress'; + } + + if (emailMatches) { return 'emailAddress'; } - if (phone_number?.enabled && isProgressiveSignUp ? phone_number.required : phone_number?.used_for_first_factor) { + if (phoneMatches) { return 'phoneNumber'; } From d52bc823c6a320e977bf5c2e912045a49f23288b Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Sun, 29 Mar 2026 15:30:09 +0300 Subject: [PATCH 4/4] docs(ui): Update preferredIdentifier TSDoc to mention SignUp --- packages/ui/src/internal/appearance.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts index b9e861062af..e9ca1a6d81d 100644 --- a/packages/ui/src/internal/appearance.ts +++ b/packages/ui/src/internal/appearance.ts @@ -965,7 +965,7 @@ export type Options = { unsafe_disableDevelopmentModeWarnings?: boolean; /** - * Controls which identifier type is shown first on the SignIn component + * Controls which identifier type is shown first on the SignIn and SignUp components * when multiple identifiers are enabled. Does not prefill any value. * * @default undefined