From 3dc0055c7eb2705bc713a83048eda8f858eaad42 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 15 Jan 2026 14:04:33 -0500 Subject: [PATCH 1/9] feat(clerk-js): Add `reset` method to SignUp resource --- .../clerk-js/src/core/resources/SignUp.ts | 18 +++ .../core/resources/__tests__/SignUp.test.ts | 125 ++++++++++++++++++ packages/shared/src/types/signUpFuture.ts | 9 ++ 3 files changed, 152 insertions(+) diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 53d3b0647d9..48b2f6e14f8 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -975,6 +975,24 @@ class SignUpFuture implements SignUpFutureResource { await SignUp.clerk.setActive({ session: this.#resource.createdSessionId, navigate }); }); } + + /** + * Resets the current sign-up attempt by creating a new empty sign-up. + * Unlike other methods, this does NOT emit resource:fetch with 'fetching' status, + * allowing for smooth UI transitions without loading states. + */ + async reset(): Promise<{ error: ClerkError | null }> { + // Clear any previous errors + eventBus.emit('resource:error', { resource: this.#resource, error: null }); + + try { + await this.#resource.__internal_basePost({ path: this.#resource.pathRoot, body: {} }); + return { error: null }; + } catch (err) { + eventBus.emit('resource:error', { resource: this.#resource, error: err }); + return { error: err }; + } + } } class SignUpEnterpriseConnection extends BaseResource implements SignUpEnterpriseConnectionResource { 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..a60fa84cf27 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,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { eventBus } from '../../events'; import { BaseResource } from '../internal'; import { SignUp } from '../SignUp'; @@ -701,5 +702,129 @@ describe('SignUp', () => { expect(result.error).toBeInstanceOf(Error); }); }); + + describe('reset', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it('creates a new signup by POSTing to /client/sign_ups with empty body', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_new', status: 'missing_requirements' }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any); + await signUp.__internal_future.reset(); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ups', + body: {}, + }), + ); + }); + + it('does NOT emit resource:fetch with status fetching', async () => { + const emitSpy = vi.spyOn(eventBus, 'emit'); + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_new', status: 'missing_requirements' }, + }); + 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); + }); + + it('clears any previous errors by emitting resource:error with null', async () => { + const emitSpy = vi.spyOn(eventBus, 'emit'); + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_new', status: 'missing_requirements' }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any); + await signUp.__internal_future.reset(); + + // Verify that resource:error was called to clear previous errors + expect(emitSpy).toHaveBeenCalledWith('resource:error', { + resource: signUp, + error: null, + }); + }); + + it('returns error: null on success', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_new', status: 'missing_requirements' }, + }); + BaseResource._fetch = mockFetch; + + 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('returns error and emits resource:error on failure', async () => { + const emitSpy = vi.spyOn(eventBus, 'emit'); + const mockError = new Error('API error'); + const mockFetch = vi.fn().mockRejectedValue(mockError); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any); + const result = await signUp.__internal_future.reset(); + + expect(result.error).toBe(mockError); + expect(emitSpy).toHaveBeenCalledWith('resource:error', { + resource: signUp, + error: mockError, + }); + }); + + it('resets an existing signup with data to a fresh state', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { + id: 'signup_new', + status: 'missing_requirements', + email_address: null, + phone_number: null, + first_name: null, + last_name: null, + }, + }); + BaseResource._fetch = mockFetch; + + 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.emailAddress).toBe('user@example.com'); + expect(signUp.firstName).toBe('John'); + + await signUp.__internal_future.reset(); + + // After reset, the signup should have new values from the response + expect(signUp.id).toBe('signup_new'); + expect(signUp.emailAddress).toBeNull(); + expect(signUp.firstName).toBeNull(); + }); + }); }); }); diff --git a/packages/shared/src/types/signUpFuture.ts b/packages/shared/src/types/signUpFuture.ts index d44ba2e534c..68891edc7b8 100644 --- a/packages/shared/src/types/signUpFuture.ts +++ b/packages/shared/src/types/signUpFuture.ts @@ -463,4 +463,13 @@ 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 creating a new empty sign-up. 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'`, allowing for + * smooth UI transitions without loading states. + */ + reset: () => Promise<{ error: ClerkError | null }>; } From df89c907a354548ab698f9036cbe338a9fae8954 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 15 Jan 2026 14:28:18 -0500 Subject: [PATCH 2/9] set to null to avoid api call --- .../clerk-js/src/core/resources/SignUp.ts | 19 ++-- .../core/resources/__tests__/SignUp.test.ts | 99 +++++-------------- packages/shared/src/types/signUpFuture.ts | 9 +- 3 files changed, 38 insertions(+), 89 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 48b2f6e14f8..068d5de0b23 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 { signUpResourceSignal, signUpErrorSignal } from '../signals'; import { BaseResource, SignUpVerifications } from './internal'; declare global { @@ -977,21 +978,19 @@ class SignUpFuture implements SignUpFutureResource { } /** - * Resets the current sign-up attempt by creating a new empty sign-up. + * 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. */ async reset(): Promise<{ error: ClerkError | null }> { - // Clear any previous errors - eventBus.emit('resource:error', { resource: this.#resource, error: null }); + // Clear errors + signUpErrorSignal({ error: null }); - try { - await this.#resource.__internal_basePost({ path: this.#resource.pathRoot, body: {} }); - return { error: null }; - } catch (err) { - eventBus.emit('resource:error', { resource: this.#resource, error: err }); - return { error: err }; - } + // Create a fresh null SignUp instance and update the signal directly + const freshSignUp = new SignUp(null); + signUpResourceSignal({ resource: freshSignUp }); + + return { error: null }; } } 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 a60fa84cf27..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,6 +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'; @@ -707,33 +708,14 @@ describe('SignUp', () => { afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); - }); - - it('creates a new signup by POSTing to /client/sign_ups with empty body', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - client: null, - response: { id: 'signup_new', status: 'missing_requirements' }, - }); - BaseResource._fetch = mockFetch; - - const signUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any); - await signUp.__internal_future.reset(); - - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'POST', - path: '/client/sign_ups', - body: {}, - }), - ); + // 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().mockResolvedValue({ - client: null, - response: { id: 'signup_new', status: 'missing_requirements' }, - }); + const mockFetch = vi.fn(); BaseResource._fetch = mockFetch; const signUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any); @@ -744,69 +726,30 @@ describe('SignUp', () => { 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 emitting resource:error with null', async () => { - const emitSpy = vi.spyOn(eventBus, 'emit'); - const mockFetch = vi.fn().mockResolvedValue({ - client: null, - response: { id: 'signup_new', status: 'missing_requirements' }, - }); - BaseResource._fetch = mockFetch; + 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 resource:error was called to clear previous errors - expect(emitSpy).toHaveBeenCalledWith('resource:error', { - resource: signUp, - error: null, - }); + // Verify that error signal was cleared + expect(signUpErrorSignal().error).toBeNull(); }); it('returns error: null on success', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - client: null, - response: { id: 'signup_new', status: 'missing_requirements' }, - }); - BaseResource._fetch = mockFetch; - 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('returns error and emits resource:error on failure', async () => { - const emitSpy = vi.spyOn(eventBus, 'emit'); - const mockError = new Error('API error'); - const mockFetch = vi.fn().mockRejectedValue(mockError); - BaseResource._fetch = mockFetch; - - const signUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any); - const result = await signUp.__internal_future.reset(); - - expect(result.error).toBe(mockError); - expect(emitSpy).toHaveBeenCalledWith('resource:error', { - resource: signUp, - error: mockError, - }); - }); - - it('resets an existing signup with data to a fresh state', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - client: null, - response: { - id: 'signup_new', - status: 'missing_requirements', - email_address: null, - phone_number: null, - first_name: null, - last_name: null, - }, - }); - BaseResource._fetch = mockFetch; - + it('resets an existing signup with data to a fresh null state', async () => { const signUp = new SignUp({ id: 'signup_123', status: 'missing_requirements', @@ -815,15 +758,21 @@ describe('SignUp', () => { } 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(); - // After reset, the signup should have new values from the response - expect(signUp.id).toBe('signup_new'); - expect(signUp.emailAddress).toBeNull(); - expect(signUp.firstName).toBeNull(); + // 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/shared/src/types/signUpFuture.ts b/packages/shared/src/types/signUpFuture.ts index 68891edc7b8..5793c271852 100644 --- a/packages/shared/src/types/signUpFuture.ts +++ b/packages/shared/src/types/signUpFuture.ts @@ -465,11 +465,12 @@ export interface SignUpFutureResource { finalize: (params?: SignUpFutureFinalizeParams) => Promise<{ error: ClerkError | null }>; /** - * Resets the current sign-up attempt by creating a new empty sign-up. 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). + * 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'`, allowing for - * smooth UI transitions without loading states. + * 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 }>; } From 931c1e439273bba8fcc02e6b2862a916d00b23d1 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 15 Jan 2026 14:32:25 -0500 Subject: [PATCH 3/9] fix linting --- packages/clerk-js/src/core/resources/SignUp.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 068d5de0b23..ca8decd6eab 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -62,7 +62,7 @@ import { clerkVerifyWeb3WalletCalledBeforeCreate, } from '../errors'; import { eventBus } from '../events'; -import { signUpResourceSignal, signUpErrorSignal } from '../signals'; +import { signUpErrorSignal, signUpResourceSignal } from '../signals'; import { BaseResource, SignUpVerifications } from './internal'; declare global { @@ -982,7 +982,7 @@ class SignUpFuture implements SignUpFutureResource { * Unlike other methods, this does NOT emit resource:fetch with 'fetching' status, * allowing for smooth UI transitions without loading states. */ - async reset(): Promise<{ error: ClerkError | null }> { + reset(): Promise<{ error: ClerkError | null }> { // Clear errors signUpErrorSignal({ error: null }); @@ -990,7 +990,7 @@ class SignUpFuture implements SignUpFutureResource { const freshSignUp = new SignUp(null); signUpResourceSignal({ resource: freshSignUp }); - return { error: null }; + return Promise.resolve({ error: null }); } } From 921e6461443c8f2cafd4e7b99be986364f9685a5 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 15 Jan 2026 15:11:45 -0500 Subject: [PATCH 4/9] add changeset --- .changeset/cozy-webs-matter.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/cozy-webs-matter.md 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. From 7f67200c4b43d761ec91c0e3c988bdfae6f3c810 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 15 Jan 2026 16:20:11 -0500 Subject: [PATCH 5/9] Update stateProxy.ts --- packages/react/src/stateProxy.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index 52d114f4dfd..5da09ed21d4 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -257,6 +257,9 @@ export class StateProxy implements State { get isTransferable() { return gateProperty(target, 'isTransferable', false); }, + get existingSession() { + return gateProperty(target, 'existingSession', undefined); + }, get hasBeenFinalized() { return gateProperty(target, 'hasBeenFinalized', false); }, @@ -268,6 +271,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', From 72250244c24fcb48dd93b36b59b49c690d2b5234 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 15 Jan 2026 16:22:22 -0500 Subject: [PATCH 6/9] Update stateProxy.ts --- packages/react/src/stateProxy.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index 5da09ed21d4..2eb8aa2db8b 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -257,9 +257,6 @@ export class StateProxy implements State { get isTransferable() { return gateProperty(target, 'isTransferable', false); }, - get existingSession() { - return gateProperty(target, 'existingSession', undefined); - }, get hasBeenFinalized() { return gateProperty(target, 'hasBeenFinalized', false); }, From 8fd50e2ea639d0a5fc41980a4967d81b075bd002 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 16 Jan 2026 11:21:16 -0500 Subject: [PATCH 7/9] feat: Add reset method to signInFuture resource --- .changeset/add-signin-reset.md | 6 ++ .../clerk-js/src/core/resources/SignIn.ts | 17 +++++ .../core/resources/__tests__/SignIn.test.ts | 70 +++++++++++++++++++ packages/react/src/stateProxy.ts | 1 + packages/shared/src/types/signInFuture.ts | 10 +++ 5 files changed, 104 insertions(+) create mode 100644 .changeset/add-signin-reset.md 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/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/__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/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index 2eb8aa2db8b..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( 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 }>; } From b1695c6a9f4f60b77f614c53e830ce0581ebce2e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 22 Jan 2026 16:35:54 -0500 Subject: [PATCH 8/9] PR feedback --- .../clerk-js/src/core/resources/SignIn.ts | 5 +++ .../clerk-js/src/core/resources/SignUp.ts | 5 +++ .../core/resources/__tests__/SignIn.test.ts | 28 +++++++++++++++++ .../core/resources/__tests__/SignUp.test.ts | 31 +++++++++++++++++++ 4 files changed, 69 insertions(+) diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 074a85fc212..4e29a4d113e 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -1261,6 +1261,11 @@ class SignInFuture implements SignInFutureResource { const freshSignIn = new SignIn(null); signInResourceSignal({ resource: freshSignIn }); + // Also update clerk.client.signIn + if (SignIn.clerk?.client) { + SignIn.clerk.client.signIn = freshSignIn; + } + return Promise.resolve({ error: null }); } diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index ca8decd6eab..588f2be3e00 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -990,6 +990,11 @@ class SignUpFuture implements SignUpFutureResource { const freshSignUp = new SignUp(null); signUpResourceSignal({ resource: freshSignUp }); + // Also update clerk.client.signUp + if (SignUp.clerk?.client) { + SignUp.clerk.client.signUp = freshSignUp; + } + return Promise.resolve({ error: null }); } } 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 24103b90e76..f7aaa3c9f7f 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -1959,6 +1959,34 @@ describe('SignIn', () => { 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); + + // Set up mock clerk.client + const mockClient = { + signIn: originalSignIn, + }; + SignIn.clerk = { + client: mockClient, + } as any; + + // 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 1f81f43c09d..d9e6d9d071a 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -774,6 +774,37 @@ describe('SignUp', () => { 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); + + // Set up mock clerk.client + const mockClient = { + signUp: originalSignUp, + }; + SignUp.clerk = { + client: mockClient, + } as any; + + // 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(); + }); }); }); }); From 66fc05d6231643b7ffe03ef0cf7e326d3437beca Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:08:41 -0600 Subject: [PATCH 9/9] feat(clerk-js): Improve reset mechanism (#7662) --- .../clerk-js/src/core/resources/Client.ts | 13 +++++++ .../clerk-js/src/core/resources/SignIn.ts | 24 +++++-------- .../clerk-js/src/core/resources/SignUp.ts | 24 +++++-------- .../core/resources/__tests__/SignIn.test.ts | 34 ++++++++++++++----- .../core/resources/__tests__/SignUp.test.ts | 32 ++++++++++++----- packages/clerk-js/src/core/state.ts | 6 ++-- packages/react/src/stateProxy.ts | 8 ++--- packages/shared/src/types/client.ts | 2 ++ packages/shared/src/types/signInFuture.ts | 4 +-- packages/shared/src/types/signUpFuture.ts | 4 +-- 10 files changed, 91 insertions(+), 60 deletions(-) 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 4e29a4d113e..998fb276c03 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -92,7 +92,6 @@ 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 { @@ -651,7 +650,7 @@ class SignInFuture implements SignInFutureResource { verifyBackupCode: this.verifyBackupCode.bind(this), }; - #hasBeenFinalized = false; + #canBeDiscarded = false; readonly #resource: SignIn; constructor(resource: SignIn) { @@ -711,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 }> { @@ -1243,7 +1242,7 @@ class SignInFuture implements SignInFutureResource { await SignIn.clerk.client.reload(); } - this.#hasBeenFinalized = true; + this.#canBeDiscarded = true; await SignIn.clerk.setActive({ session: this.#resource.createdSessionId, navigate }); }); } @@ -1254,18 +1253,11 @@ class SignInFuture implements SignInFutureResource { * 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 }); - - // Also update clerk.client.signIn - if (SignIn.clerk?.client) { - SignIn.clerk.client.signIn = freshSignIn; + 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 }); } diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 588f2be3e00..d6d19a83997 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -62,7 +62,6 @@ import { clerkVerifyWeb3WalletCalledBeforeCreate, } from '../errors'; import { eventBus } from '../events'; -import { signUpErrorSignal, signUpResourceSignal } from '../signals'; import { BaseResource, SignUpVerifications } from './internal'; declare global { @@ -597,7 +596,7 @@ class SignUpFuture implements SignUpFutureResource { verifyPhoneCode: this.verifyPhoneCode.bind(this), }; - #hasBeenFinalized = false; + #canBeDiscarded = false; readonly #resource: SignUp; constructor(resource: SignUp) { @@ -702,8 +701,8 @@ class SignUpFuture implements SignUpFutureResource { return undefined; } - get hasBeenFinalized() { - return this.#hasBeenFinalized; + get canBeDiscarded() { + return this.#canBeDiscarded; } private async getCaptchaToken(): Promise<{ @@ -972,7 +971,7 @@ 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 }); }); } @@ -983,18 +982,11 @@ class SignUpFuture implements SignUpFutureResource { * 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 }); - - // Also update clerk.client.signUp - if (SignUp.clerk?.client) { - SignUp.clerk.client.signUp = freshSignUp; + 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 }); } } 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 f7aaa3c9f7f..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,4 +1,4 @@ -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'; @@ -1894,6 +1894,29 @@ describe('SignIn', () => { }); 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(); @@ -1966,14 +1989,7 @@ describe('SignIn', () => { status: 'needs_first_factor', identifier: 'user@example.com', } as any); - - // Set up mock clerk.client - const mockClient = { - signIn: originalSignIn, - }; - SignIn.clerk = { - client: mockClient, - } as any; + mockClient.signIn = originalSignIn; // Verify initial state expect(mockClient.signIn.id).toBe('signin_123'); 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 d9e6d9d071a..d188118e2b5 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -705,6 +705,29 @@ describe('SignUp', () => { }); 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(); @@ -782,14 +805,7 @@ describe('SignUp', () => { email_address: 'user@example.com', first_name: 'John', } as any); - - // Set up mock clerk.client - const mockClient = { - signUp: originalSignUp, - }; - SignUp.clerk = { - client: mockClient, - } as any; + mockClient.signUp = originalSignUp; // Verify initial state expect(mockClient.signUp.id).toBe('signup_123'); 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 deac6afcef1..a42d7fb06ff 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -150,8 +150,8 @@ export class StateProxy implements State { }, }); }, - get hasBeenFinalized() { - return gateProperty(target, 'hasBeenFinalized', false); + get canBeDiscarded() { + return gateProperty(target, 'canBeDiscarded', false); }, create: this.gateMethod(target, 'create'), @@ -258,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'), 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 f7376462c25..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 diff --git a/packages/shared/src/types/signUpFuture.ts b/packages/shared/src/types/signUpFuture.ts index 5793c271852..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