diff --git a/.changeset/add-signin-reset.md b/.changeset/add-signin-reset.md new file mode 100644 index 00000000000..ea79c972839 --- /dev/null +++ b/.changeset/add-signin-reset.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/shared': patch +--- + +Add `reset` method to the sign-in resource. diff --git a/.changeset/cozy-webs-matter.md b/.changeset/cozy-webs-matter.md new file mode 100644 index 00000000000..f10d294ea54 --- /dev/null +++ b/.changeset/cozy-webs-matter.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/shared': patch +--- + +Add `reset` method to the new signUp resource. diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 2ceec76dd3a..074a85fc212 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -92,6 +92,7 @@ import { clerkVerifyWeb3WalletCalledBeforeCreate, } from '../errors'; import { eventBus } from '../events'; +import { signInErrorSignal, signInResourceSignal } from '../signals'; import { BaseResource, UserData, Verification } from './internal'; export class SignIn extends BaseResource implements SignInResource { @@ -1247,6 +1248,22 @@ class SignInFuture implements SignInFutureResource { }); } + /** + * Resets the current sign-in attempt by clearing all local state back to null. + * Unlike other methods, this does NOT emit resource:fetch with 'fetching' status, + * allowing for smooth UI transitions without loading states. + */ + reset(): Promise<{ error: ClerkError | null }> { + // Clear errors + signInErrorSignal({ error: null }); + + // Create a fresh null SignIn instance and update the signal directly + const freshSignIn = new SignIn(null); + signInResourceSignal({ resource: freshSignIn }); + + return Promise.resolve({ error: null }); + } + private selectFirstFactor( params: Extract, ): EmailCodeFactor | null; diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 53d3b0647d9..ca8decd6eab 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -62,6 +62,7 @@ import { clerkVerifyWeb3WalletCalledBeforeCreate, } from '../errors'; import { eventBus } from '../events'; +import { signUpErrorSignal, signUpResourceSignal } from '../signals'; import { BaseResource, SignUpVerifications } from './internal'; declare global { @@ -975,6 +976,22 @@ class SignUpFuture implements SignUpFutureResource { await SignUp.clerk.setActive({ session: this.#resource.createdSessionId, navigate }); }); } + + /** + * Resets the current sign-up attempt by clearing all local state back to null. + * Unlike other methods, this does NOT emit resource:fetch with 'fetching' status, + * allowing for smooth UI transitions without loading states. + */ + reset(): Promise<{ error: ClerkError | null }> { + // Clear errors + signUpErrorSignal({ error: null }); + + // Create a fresh null SignUp instance and update the signal directly + const freshSignUp = new SignUp(null); + signUpResourceSignal({ resource: freshSignUp }); + + return Promise.resolve({ error: null }); + } } class SignUpEnterpriseConnection extends BaseResource implements SignUpEnterpriseConnectionResource { diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts index db972315dcb..934fea4508b 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { eventBus } from '../../events'; +import { signInErrorSignal, signInResourceSignal } from '../../signals'; import { BaseResource } from '../internal'; import { SignIn } from '../SignIn'; @@ -1890,5 +1892,73 @@ describe('SignIn', () => { await expect(signIn.__internal_future.finalize()).rejects.toThrow(); }); }); + + describe('reset', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + // Reset signals to initial state + signInResourceSignal({ resource: null }); + signInErrorSignal({ error: null }); + }); + + it('does NOT emit resource:fetch with status fetching', async () => { + const emitSpy = vi.spyOn(eventBus, 'emit'); + const mockFetch = vi.fn(); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ id: 'signin_123', status: 'needs_first_factor' } as any); + await signIn.__internal_future.reset(); + + // Verify that resource:fetch was NOT called with status: 'fetching' + const fetchingCalls = emitSpy.mock.calls.filter( + call => call[0] === 'resource:fetch' && call[1]?.status === 'fetching', + ); + expect(fetchingCalls).toHaveLength(0); + // Verify no API calls were made + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('clears any previous errors by updating signInErrorSignal', async () => { + // Set an initial error + signInErrorSignal({ error: new Error('Previous error') }); + expect(signInErrorSignal().error).toBeTruthy(); + + const signIn = new SignIn({ id: 'signin_123', status: 'needs_first_factor' } as any); + await signIn.__internal_future.reset(); + + // Verify that error signal was cleared + expect(signInErrorSignal().error).toBeNull(); + }); + + it('returns error: null on success', async () => { + const signIn = new SignIn({ id: 'signin_123', status: 'needs_first_factor' } as any); + const result = await signIn.__internal_future.reset(); + + expect(result).toHaveProperty('error', null); + }); + + it('resets an existing signin with data to a fresh null state', async () => { + const signIn = new SignIn({ + id: 'signin_123', + status: 'needs_first_factor', + identifier: 'user@example.com', + } as any); + + // Verify initial state + expect(signIn.id).toBe('signin_123'); + expect(signIn.status).toBe('needs_first_factor'); + expect(signIn.identifier).toBe('user@example.com'); + + await signIn.__internal_future.reset(); + + // Verify that signInResourceSignal was updated with a new SignIn(null) instance + const updatedSignIn = signInResourceSignal().resource; + expect(updatedSignIn).toBeInstanceOf(SignIn); + expect(updatedSignIn?.id).toBeUndefined(); + expect(updatedSignIn?.status).toBeNull(); + expect(updatedSignIn?.identifier).toBeNull(); + }); + }); }); }); diff --git a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts index 663fa63d6d2..1f81f43c09d 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { eventBus } from '../../events'; +import { signUpErrorSignal, signUpResourceSignal } from '../../signals'; import { BaseResource } from '../internal'; import { SignUp } from '../SignUp'; @@ -701,5 +703,77 @@ describe('SignUp', () => { expect(result.error).toBeInstanceOf(Error); }); }); + + describe('reset', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + // Reset signals to initial state + signUpResourceSignal({ resource: null }); + signUpErrorSignal({ error: null }); + }); + + it('does NOT emit resource:fetch with status fetching', async () => { + const emitSpy = vi.spyOn(eventBus, 'emit'); + const mockFetch = vi.fn(); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any); + await signUp.__internal_future.reset(); + + // Verify that resource:fetch was NOT called with status: 'fetching' + const fetchingCalls = emitSpy.mock.calls.filter( + call => call[0] === 'resource:fetch' && call[1]?.status === 'fetching', + ); + expect(fetchingCalls).toHaveLength(0); + // Verify no API calls were made + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('clears any previous errors by updating signUpErrorSignal', async () => { + // Set an initial error + signUpErrorSignal({ error: new Error('Previous error') }); + expect(signUpErrorSignal().error).toBeTruthy(); + + const signUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any); + await signUp.__internal_future.reset(); + + // Verify that error signal was cleared + expect(signUpErrorSignal().error).toBeNull(); + }); + + it('returns error: null on success', async () => { + const signUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any); + const result = await signUp.__internal_future.reset(); + + expect(result).toHaveProperty('error', null); + }); + + it('resets an existing signup with data to a fresh null state', async () => { + const signUp = new SignUp({ + id: 'signup_123', + status: 'missing_requirements', + email_address: 'user@example.com', + first_name: 'John', + } as any); + + // Verify initial state + expect(signUp.id).toBe('signup_123'); + expect(signUp.emailAddress).toBe('user@example.com'); + expect(signUp.firstName).toBe('John'); + + await signUp.__internal_future.reset(); + + // Verify that signUpResourceSignal was updated with a new SignUp(null) instance + const updatedSignUp = signUpResourceSignal().resource; + expect(updatedSignUp).toBeInstanceOf(SignUp); + expect(updatedSignUp?.id).toBeUndefined(); + expect(updatedSignUp?.status).toBeNull(); + expect(updatedSignUp?.emailAddress).toBeNull(); + expect(updatedSignUp?.firstName).toBeNull(); + expect(updatedSignUp?.lastName).toBeNull(); + expect(updatedSignUp?.phoneNumber).toBeNull(); + }); + }); }); }); diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index 52d114f4dfd..deac6afcef1 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -158,6 +158,7 @@ export class StateProxy implements State { password: this.gateMethod(target, 'password'), sso: this.gateMethod(target, 'sso'), finalize: this.gateMethod(target, 'finalize'), + reset: this.gateMethod(target, 'reset'), emailCode: this.wrapMethods(() => target().emailCode, ['sendCode', 'verifyCode'] as const), emailLink: this.wrapStruct( @@ -268,6 +269,7 @@ export class StateProxy implements State { ticket: gateMethod(target, 'ticket'), web3: gateMethod(target, 'web3'), finalize: gateMethod(target, 'finalize'), + reset: gateMethod(target, 'reset'), verifications: wrapMethods(() => target().verifications, [ 'sendEmailCode', diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index fa673bb4d66..f7376462c25 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -519,4 +519,14 @@ export interface SignInFutureResource { * session state (such as the `useUser()` hook) to update automatically. */ finalize: (params?: SignInFutureFinalizeParams) => Promise<{ error: ClerkError | null }>; + + /** + * Resets the current sign-in attempt by clearing all local state back to null. + * This is useful when you want to allow users to go back to the beginning of + * the sign-in flow (e.g., to change their identifier during verification). + * + * Unlike other methods, `reset()` does not trigger the `fetchStatus` to change + * to `'fetching'` and does not make any API calls - it only clears local state. + */ + reset: () => Promise<{ error: ClerkError | null }>; } diff --git a/packages/shared/src/types/signUpFuture.ts b/packages/shared/src/types/signUpFuture.ts index d44ba2e534c..5793c271852 100644 --- a/packages/shared/src/types/signUpFuture.ts +++ b/packages/shared/src/types/signUpFuture.ts @@ -463,4 +463,14 @@ export interface SignUpFutureResource { * session state (such as the `useUser()` hook) to update automatically. */ finalize: (params?: SignUpFutureFinalizeParams) => Promise<{ error: ClerkError | null }>; + + /** + * Resets the current sign-up attempt by clearing all local state back to null. + * This is useful when you want to allow users to go back to the beginning of + * the sign-up flow (e.g., to change their email address during verification). + * + * Unlike other methods, `reset()` does not trigger the `fetchStatus` to change + * to `'fetching'` and does not make any API calls - it only clears local state. + */ + reset: () => Promise<{ error: ClerkError | null }>; }