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();
+ });
+ });
+});