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/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index f246d7efbad..9247c5dbdcd 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -9,6 +9,7 @@ import type { } from '@clerk/shared/types'; import { unixEpochToDate } from '../../utils/date'; +import { eventBus } from '../events'; import { SessionTokenCache } from '../tokenCache'; import { BaseResource, Session, SignIn, SignUp } from './internal'; @@ -93,6 +94,18 @@ export class Client extends BaseResource implements ClientResource { }); } + resetSignIn(): void { + this.signIn = new SignIn(null); + // Cast needed because this.signIn is typed as SignInResource (interface), not SignIn (class extending BaseResource) + eventBus.emit('resource:error', { resource: this.signIn as SignIn, error: null }); + } + + resetSignUp(): void { + this.signUp = new SignUp(null); + // Cast needed because this.signUp is typed as SignUpResource (interface), not SignUp (class extending BaseResource) + eventBus.emit('resource:error', { resource: this.signUp as SignUp, error: null }); + } + clearCache(): void { return this.sessions.forEach(s => s.clearCache()); } diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 2ceec76dd3a..998fb276c03 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -650,7 +650,7 @@ class SignInFuture implements SignInFutureResource { verifyBackupCode: this.verifyBackupCode.bind(this), }; - #hasBeenFinalized = false; + #canBeDiscarded = false; readonly #resource: SignIn; constructor(resource: SignIn) { @@ -710,8 +710,8 @@ class SignInFuture implements SignInFutureResource { return this.#resource.secondFactorVerification; } - get hasBeenFinalized() { - return this.#hasBeenFinalized; + get canBeDiscarded() { + return this.#canBeDiscarded; } async sendResetPasswordEmailCode(): Promise<{ error: ClerkError | null }> { @@ -1242,11 +1242,25 @@ class SignInFuture implements SignInFutureResource { await SignIn.clerk.client.reload(); } - this.#hasBeenFinalized = true; + this.#canBeDiscarded = true; await SignIn.clerk.setActive({ session: this.#resource.createdSessionId, navigate }); }); } + /** + * 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 }> { + if (!SignIn.clerk.client) { + throw new Error('Cannot reset sign-in without a client.'); + } + this.#canBeDiscarded = true; + SignIn.clerk.client.resetSignIn(); + 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..d6d19a83997 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -596,7 +596,7 @@ class SignUpFuture implements SignUpFutureResource { verifyPhoneCode: this.verifyPhoneCode.bind(this), }; - #hasBeenFinalized = false; + #canBeDiscarded = false; readonly #resource: SignUp; constructor(resource: SignUp) { @@ -701,8 +701,8 @@ class SignUpFuture implements SignUpFutureResource { return undefined; } - get hasBeenFinalized() { - return this.#hasBeenFinalized; + get canBeDiscarded() { + return this.#canBeDiscarded; } private async getCaptchaToken(): Promise<{ @@ -971,10 +971,24 @@ class SignUpFuture implements SignUpFutureResource { throw new Error('Cannot finalize sign-up without a created session.'); } - this.#hasBeenFinalized = true; + this.#canBeDiscarded = true; 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 }> { + if (!SignUp.clerk.client) { + throw new Error('Cannot reset sign-up without a client.'); + } + this.#canBeDiscarded = true; + SignUp.clerk.client.resetSignUp(); + 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 a2e905a854c..2fcdd4b0d10 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 { afterEach, beforeAll, beforeEach, 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,117 @@ describe('SignIn', () => { await expect(signIn.__internal_future.finalize()).rejects.toThrow(); }); }); + + describe('reset', () => { + let mockClient: { signIn: SignIn; resetSignIn: ReturnType }; + + beforeEach(() => { + // Set up mock client with resetSignIn method that simulates what the real + // Client.resetSignIn does: creates a new SignIn, updates signals via events, + // and the State class responds by updating the actual signal values + mockClient = { + signIn: new SignIn(null), + resetSignIn: vi.fn().mockImplementation(function (this: typeof mockClient) { + const newSignIn = new SignIn(null); + this.signIn = newSignIn; + // Emit events like the real implementation + eventBus.emit('resource:error', { resource: newSignIn, error: null }); + // Also update signals directly since State isn't set up in tests + signInResourceSignal({ resource: newSignIn }); + signInErrorSignal({ error: null }); + }), + }; + SignIn.clerk = { + client: mockClient, + } as any; + }); + + 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(); + }); + + it('updates clerk.client.signIn with the fresh null instance', async () => { + const originalSignIn = new SignIn({ + id: 'signin_123', + status: 'needs_first_factor', + identifier: 'user@example.com', + } as any); + mockClient.signIn = originalSignIn; + + // Verify initial state + expect(mockClient.signIn.id).toBe('signin_123'); + expect(mockClient.signIn.status).toBe('needs_first_factor'); + + await originalSignIn.__internal_future.reset(); + + // Verify that clerk.client.signIn was updated with a new SignIn(null) instance + expect(mockClient.signIn).toBeInstanceOf(SignIn); + expect(mockClient.signIn.id).toBeUndefined(); + expect(mockClient.signIn.status).toBeNull(); + expect(mockClient.signIn.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..d188118e2b5 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,124 @@ describe('SignUp', () => { expect(result.error).toBeInstanceOf(Error); }); }); + + describe('reset', () => { + let mockClient: { signUp: SignUp; resetSignUp: ReturnType }; + + beforeEach(() => { + // Set up mock client with resetSignUp method that simulates what the real + // Client.resetSignUp does: creates a new SignUp, updates signals via events, + // and the State class responds by updating the actual signal values + mockClient = { + signUp: new SignUp(null), + resetSignUp: vi.fn().mockImplementation(function (this: typeof mockClient) { + const newSignUp = new SignUp(null); + this.signUp = newSignUp; + // Emit events like the real implementation + eventBus.emit('resource:error', { resource: newSignUp, error: null }); + // Also update signals directly since State isn't set up in tests + signUpResourceSignal({ resource: newSignUp }); + signUpErrorSignal({ error: null }); + }), + }; + SignUp.clerk = { + client: mockClient, + } as any; + }); + + 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(); + }); + + it('updates clerk.client.signUp with the fresh null instance', async () => { + const originalSignUp = new SignUp({ + id: 'signup_123', + status: 'missing_requirements', + email_address: 'user@example.com', + first_name: 'John', + } as any); + mockClient.signUp = originalSignUp; + + // Verify initial state + expect(mockClient.signUp.id).toBe('signup_123'); + expect(mockClient.signUp.status).toBe('missing_requirements'); + expect(mockClient.signUp.emailAddress).toBe('user@example.com'); + + await originalSignUp.__internal_future.reset(); + + // Verify that clerk.client.signUp was updated with a new SignUp(null) instance + expect(mockClient.signUp).toBeInstanceOf(SignUp); + expect(mockClient.signUp.id).toBeUndefined(); + expect(mockClient.signUp.status).toBeNull(); + expect(mockClient.signUp.emailAddress).toBeNull(); + expect(mockClient.signUp.firstName).toBeNull(); + }); + }); }); }); diff --git a/packages/clerk-js/src/core/state.ts b/packages/clerk-js/src/core/state.ts index d601cdc581d..fb5909f6bba 100644 --- a/packages/clerk-js/src/core/state.ts +++ b/packages/clerk-js/src/core/state.ts @@ -109,11 +109,11 @@ export class State implements StateInterface { } /** - * Returns true if the new resource is null and the previous resource has not been finalized. This is used to prevent - * nullifying the resource after it's been completed. + * Returns true if the new resource is null and the previous resource cannot be discarded. This is used to prevent + * nullifying the resource after it's been completed or explicitly reset. */ function shouldIgnoreNullUpdate(previousResource: SignIn | null, newResource: SignIn | null): boolean; function shouldIgnoreNullUpdate(previousResource: SignUp | null, newResource: SignUp | null): boolean; function shouldIgnoreNullUpdate(previousResource: SignIn | SignUp | null, newResource: SignIn | SignUp | null) { - return !newResource?.id && previousResource && previousResource.__internal_future?.hasBeenFinalized === false; + return !newResource?.id && previousResource && previousResource.__internal_future?.canBeDiscarded === false; } diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index 52d114f4dfd..a42d7fb06ff 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -150,14 +150,15 @@ export class StateProxy implements State { }, }); }, - get hasBeenFinalized() { - return gateProperty(target, 'hasBeenFinalized', false); + get canBeDiscarded() { + return gateProperty(target, 'canBeDiscarded', false); }, create: this.gateMethod(target, 'create'), 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( @@ -257,8 +258,8 @@ export class StateProxy implements State { get isTransferable() { return gateProperty(target, 'isTransferable', false); }, - get hasBeenFinalized() { - return gateProperty(target, 'hasBeenFinalized', false); + get canBeDiscarded() { + return gateProperty(target, 'canBeDiscarded', false); }, create: gateMethod(target, 'create'), @@ -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/client.ts b/packages/shared/src/types/client.ts index 51757fb39ee..1c89ed554aa 100644 --- a/packages/shared/src/types/client.ts +++ b/packages/shared/src/types/client.ts @@ -15,6 +15,8 @@ export interface ClientResource extends ClerkResource { destroy: () => Promise; removeSessions: () => Promise; clearCache: () => void; + resetSignIn: () => void; + resetSignUp: () => void; isEligibleForTouch: () => boolean; buildTouchUrl: (params: { redirectUrl: URL }) => string; lastActiveSessionId: string | null; diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index fa673bb4d66..12e21236970 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -344,11 +344,11 @@ export interface SignInFutureResource { readonly userData: UserData; /** - * Indicates that the sign-in has been finalized. + * Indicates that the sign-in can be discarded (has been finalized or explicitly reset). * * @internal */ - readonly hasBeenFinalized: boolean; + readonly canBeDiscarded: boolean; /** * Creates a new `SignIn` instance initialized with the provided parameters. The instance maintains the sign-in @@ -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..493b80535d6 100644 --- a/packages/shared/src/types/signUpFuture.ts +++ b/packages/shared/src/types/signUpFuture.ts @@ -384,11 +384,11 @@ export interface SignUpFutureResource { readonly locale: string | null; /** - * Indicates that the sign-up has been finalized. + * Indicates that the sign-up can be discarded (has been finalized or explicitly reset). * * @internal */ - readonly hasBeenFinalized: boolean; + readonly canBeDiscarded: boolean; /** * Creates a new `SignUp` instance initialized with the provided parameters. The instance maintains the sign-up @@ -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 }>; }