diff --git a/.changeset/real-bees-post.md b/.changeset/real-bees-post.md new file mode 100644 index 00000000000..209ac39eeed --- /dev/null +++ b/.changeset/real-bees-post.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Reworked the cache key creation logic in SignInFactorOneCodeForm.tsx not to rely on sign_in.id, which can change after host app re-renders diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 94808a14f2b..97bc5789f7d 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,6 +1,6 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "608.92kB" }, + { "path": "./dist/clerk.js", "maxSize": "610kB" }, { "path": "./dist/clerk.browser.js", "maxSize": "70.16KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "113KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "53.06KB" }, diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneCodeForm.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneCodeForm.tsx index 969660be676..d0abe160e09 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneCodeForm.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneCodeForm.tsx @@ -1,6 +1,7 @@ import { isUserLockedError } from '@clerk/shared/error'; import { useClerk } from '@clerk/shared/react'; import type { EmailCodeFactor, PhoneCodeFactor, ResetPasswordCodeFactor } from '@clerk/types'; +import { useMemo } from 'react'; import { useCardState } from '@/ui/elements/contexts'; import type { VerificationCodeCardProps } from '@/ui/elements/VerificationCodeCard'; @@ -41,6 +42,31 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => const shouldAvoidPrepare = signIn.firstFactorVerification.status === 'verified' && props.factorAlreadyPrepared; + const cacheKey = useMemo(() => { + const factor = props.factor; + let factorKey = factor.strategy; + + if ('emailAddressId' in factor) { + factorKey += `_${factor.emailAddressId}`; + } + if ('phoneNumberId' in factor) { + factorKey += `_${factor.phoneNumberId}`; + } + if ('channel' in factor && factor.channel) { + factorKey += `_${factor.channel}`; + } + + return { + name: 'signIn.prepareFirstFactor', + factorKey, + }; + }, [ + props.factor.strategy, + 'emailAddressId' in props.factor ? props.factor.emailAddressId : undefined, + 'phoneNumberId' in props.factor ? props.factor.phoneNumberId : undefined, + 'channel' in props.factor ? props.factor.channel : undefined, + ]); + const goBack = () => { return navigate('../'); }; @@ -64,11 +90,7 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => ?.prepareFirstFactor(props.factor) .then(() => props.onFactorPrepare()) .catch(err => handleError(err, [], card.setError)), - { - name: 'signIn.prepareFirstFactor', - factor: props.factor, - id: signIn.id, - }, + cacheKey, { staleTime: 100, }, diff --git a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOneCodeForm.spec.tsx b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOneCodeForm.spec.tsx new file mode 100644 index 00000000000..695d43a63d1 --- /dev/null +++ b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOneCodeForm.spec.tsx @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { render } from '../../../../vitestUtils'; +import { CardStateProvider } from '../../../elements/contexts'; +import { clearFetchCache, useFetch } from '../../../hooks'; +import { localizationKeys } from '../../../localization'; +import { bindCreateFixtures } from '../../../utils/vitest/createFixtures'; +import { SignInFactorOneCodeForm } from '../SignInFactorOneCodeForm'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +vi.mock('../../../hooks', async () => { + const actual = await vi.importActual('../../../hooks'); + return { + ...actual, + useFetch: vi.fn(), + }; +}); + +describe('SignInFactorOneCodeForm', () => { + beforeEach(() => { + clearFetchCache(); + vi.mocked(useFetch).mockClear(); + }); + + const renderWithProviders = (component: React.ReactElement, options?: any) => { + return render({component}, options); + }; + + const defaultProps = { + factor: { + strategy: 'phone_code' as const, + phoneNumberId: 'idn_123', + safeIdentifier: '+1234567890', + }, + factorAlreadyPrepared: false, + onFactorPrepare: vi.fn(), + cardTitle: localizationKeys('signIn.phoneCode.title'), + cardSubtitle: localizationKeys('signIn.phoneCode.subtitle'), + inputLabel: localizationKeys('signIn.phoneCode.formTitle'), + resendButton: localizationKeys('signIn.phoneCode.resendButton'), + }; + + describe('Cache Key Generation', () => { + it('generates cache key without signIn.id to prevent extra API calls', async () => { + const { wrapper } = await createFixtures(f => { + f.withPhoneNumber(); + f.startSignInWithPhoneNumber({ supportPhoneCode: true }); + }); + + renderWithProviders(, { wrapper }); + + expect(vi.mocked(useFetch)).toHaveBeenCalledWith( + expect.any(Function), + { + name: 'signIn.prepareFirstFactor', + factorKey: 'phone_code_idn_123', + }, + { + staleTime: 100, + }, + ); + }); + + it('includes channel in cache key for phone code with WhatsApp', async () => { + const { wrapper } = await createFixtures(f => { + f.withPhoneNumber(); + f.startSignInWithPhoneNumber({ supportPhoneCode: true }); + }); + + const phonePropsWithChannel = { + factor: { + strategy: 'phone_code' as const, + phoneNumberId: 'idn_123', + safeIdentifier: '+1234567890', + channel: 'whatsapp' as const, + }, + factorAlreadyPrepared: false, + onFactorPrepare: vi.fn(), + cardTitle: localizationKeys('signIn.phoneCode.title'), + cardSubtitle: localizationKeys('signIn.phoneCode.subtitle'), + inputLabel: localizationKeys('signIn.phoneCode.formTitle'), + resendButton: localizationKeys('signIn.phoneCode.resendButton'), + }; + + renderWithProviders(, { wrapper }); + + expect(vi.mocked(useFetch)).toHaveBeenCalledWith( + expect.any(Function), + { + name: 'signIn.prepareFirstFactor', + factorKey: 'phone_code_idn_123_whatsapp', + }, + { + staleTime: 100, + }, + ); + }); + + it('still calls prepare when factor is already prepared but verification not verified', async () => { + const { wrapper } = await createFixtures(f => { + f.withPhoneNumber(); + f.startSignInWithPhoneNumber({ supportPhoneCode: true }); + }); + + const props = { + factor: { + strategy: 'phone_code' as const, + phoneNumberId: 'idn_123', + safeIdentifier: '+1234567890', + }, + factorAlreadyPrepared: true, + onFactorPrepare: vi.fn(), + cardTitle: localizationKeys('signIn.phoneCode.title'), + cardSubtitle: localizationKeys('signIn.phoneCode.subtitle'), + inputLabel: localizationKeys('signIn.phoneCode.formTitle'), + resendButton: localizationKeys('signIn.phoneCode.resendButton'), + }; + + renderWithProviders(, { wrapper }); + + expect(vi.mocked(useFetch)).toHaveBeenCalledWith(expect.any(Function), expect.any(Object), expect.any(Object)); + }); + }); + + describe('shouldAvoidPrepare Logic', () => { + it('still calls prepare when factor is already prepared but verification not verified', async () => { + const { wrapper } = await createFixtures(f => { + f.withPhoneNumber(); + f.startSignInWithPhoneNumber({ supportPhoneCode: true }); + }); + + const propsWithFactorPrepared = { + ...defaultProps, + factorAlreadyPrepared: true, + }; + + renderWithProviders(, { wrapper }); + + expect(vi.mocked(useFetch)).toHaveBeenCalledWith( + expect.any(Function), // fetcher should still be a function because shouldAvoidPrepare requires BOTH conditions + expect.any(Object), + expect.any(Object), + ); + }); + + it('allows prepare when factor is not already prepared', async () => { + const { wrapper } = await createFixtures(f => { + f.withPhoneNumber(); + f.startSignInWithPhoneNumber({ supportPhoneCode: true }); + }); + + const propsWithFactorNotPrepared = { + ...defaultProps, + factorAlreadyPrepared: false, + }; + + renderWithProviders(, { wrapper }); + + expect(vi.mocked(useFetch)).toHaveBeenCalledWith( + expect.any(Function), // fetcher should be a function when prepare is allowed + expect.any(Object), + expect.any(Object), + ); + }); + }); + + describe('Component Rendering', () => { + it('renders phone code verification form', async () => { + const { wrapper } = await createFixtures(f => { + f.withPhoneNumber(); + f.startSignInWithPhoneNumber({ supportPhoneCode: true }); + }); + + renderWithProviders(, { wrapper }); + + expect(document.querySelector('input[type="text"]')).toBeInTheDocument(); + }); + + it('renders email code verification form', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.startSignInWithEmailAddress({ supportEmailCode: true }); + }); + + const emailProps = { + factor: { + strategy: 'email_code' as const, + emailAddressId: 'idn_456', + safeIdentifier: 'test@example.com', + }, + factorAlreadyPrepared: false, + onFactorPrepare: vi.fn(), + cardTitle: localizationKeys('signIn.emailCode.title'), + cardSubtitle: localizationKeys('signIn.emailCode.subtitle'), + inputLabel: localizationKeys('signIn.emailCode.formTitle'), + resendButton: localizationKeys('signIn.emailCode.resendButton'), + }; + + renderWithProviders(, { wrapper }); + + expect(document.querySelector('input[type="text"]')).toBeInTheDocument(); + }); + }); +});