From 289cc5555c252511022682da6aebbf6dd7f62019 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 23 Jul 2026 13:28:30 -0400 Subject: [PATCH 01/14] Prevent duplicate concurrent sign-in factor preparations --- .changeset/calm-codes-share.md | 5 + .../clerk-js/src/core/resources/SignIn.ts | 31 +++- .../core/resources/__tests__/SignIn.test.ts | 169 ++++++++++++++++++ 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 .changeset/calm-codes-share.md diff --git a/.changeset/calm-codes-share.md b/.changeset/calm-codes-share.md new file mode 100644 index 00000000000..edaf39b6542 --- /dev/null +++ b/.changeset/calm-codes-share.md @@ -0,0 +1,5 @@ +--- +"@clerk/clerk-js": patch +--- + +Prevent overlapping sign-in factor preparations from triggering duplicate verification sends. diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 65a72ef1c92..97510422ebe 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -98,7 +98,17 @@ import { clerkVerifyWeb3WalletCalledBeforeCreate, } from '../errors'; import { eventBus } from '../events'; -import { BaseResource, UserData, Verification } from './internal'; +import { type BaseMutateParams, BaseResource, UserData, Verification } from './internal'; + +const isFactorPreparationAction = (action?: string): action is 'prepare_first_factor' | 'prepare_second_factor' => + action === 'prepare_first_factor' || action === 'prepare_second_factor'; + +const factorPreparationKey = (params: BaseMutateParams): string => { + const body = Object.entries(params.body ?? {}) + .filter(([, value]) => value !== undefined) + .sort(([left], [right]) => left.localeCompare(right)); + return JSON.stringify([params.path, params.action, body]); +}; export class SignIn extends BaseResource implements SignInResource { pathRoot = '/client/sign_ins'; @@ -115,6 +125,25 @@ export class SignIn extends BaseResource implements SignInResource { userData: UserData = new UserData(null); clientTrustState?: ClientTrustState; protectCheck: ProtectCheckResource | null = null; + #pendingFactorPreparations = new Map>(); + + protected override _basePost(params: BaseMutateParams = {}): Promise { + if (!isFactorPreparationAction(params.action)) { + return super._basePost(params); + } + + const key = factorPreparationKey(params); + const pending = this.#pendingFactorPreparations.get(key); + if (pending) { + return pending; + } + + const preparation = super._basePost(params).finally(() => { + this.#pendingFactorPreparations.delete(key); + }); + this.#pendingFactorPreparations.set(key, preparation); + return preparation; + } /** * The current status of the sign-in process. 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 628b665fc7c..2a094daaded 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -1,3 +1,4 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { eventBus } from '../../events'; @@ -36,6 +37,174 @@ describe('SignIn', () => { expect(snapshot).toBeDefined(); }); + describe('prepareSecondFactor', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('coalesces concurrent identical preparations', async () => { + const deferred = createDeferredPromise(); + const mockFetch = vi.fn().mockReturnValue(deferred.promise); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ id: 'signin_123' } as any); + const params = { strategy: 'email_code' as const, emailAddressId: 'email_123' }; + + const first = signIn.prepareSecondFactor(params); + const second = signIn.prepareSecondFactor(params); + + expect(mockFetch).toHaveBeenCalledTimes(1); + + deferred.resolve({ + client: null, + response: { id: 'signin_123' }, + }); + await Promise.all([first, second]); + }); + + it('does not coalesce preparations for different factors', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ id: 'signin_123' } as any); + + await Promise.all([ + signIn.prepareSecondFactor({ strategy: 'email_code', emailAddressId: 'email_123' }), + signIn.prepareSecondFactor({ strategy: 'email_code', emailAddressId: 'email_456' }), + ]); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('starts a new preparation after the previous request succeeds', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ id: 'signin_123' } as any); + const params = { strategy: 'email_code' as const, emailAddressId: 'email_123' }; + + await signIn.prepareSecondFactor(params); + await signIn.prepareSecondFactor(params); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('starts a new preparation after the previous request fails', async () => { + const mockFetch = vi + .fn() + .mockRejectedValueOnce(new Error('prepare failed')) + .mockResolvedValueOnce({ + client: null, + response: { id: 'signin_123' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ id: 'signin_123' } as any); + const params = { strategy: 'email_code' as const, emailAddressId: 'email_123' }; + + await expect(signIn.prepareSecondFactor(params)).rejects.toThrow('prepare failed'); + await signIn.prepareSecondFactor(params); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); + + describe('factor preparation across APIs', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('coalesces concurrent first-factor preparations', async () => { + const deferred = createDeferredPromise(); + const mockFetch = vi.fn().mockReturnValue(deferred.promise); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ id: 'signin_123' } as any); + const params = { strategy: 'email_code' as const, emailAddressId: 'email_123' }; + + const first = signIn.prepareFirstFactor(params); + const second = signIn.prepareFirstFactor(params); + + expect(mockFetch).toHaveBeenCalledTimes(1); + + deferred.resolve({ + client: null, + response: { id: 'signin_123' }, + }); + await Promise.all([first, second]); + }); + + it('coalesces concurrent first-factor preparations from the future API', async () => { + const deferred = createDeferredPromise(); + const mockFetch = vi.fn().mockReturnValue(deferred.promise); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ id: 'signin_123' } as any); + signIn.supportedFirstFactors = [ + { strategy: 'email_code', emailAddressId: 'email_123', safeIdentifier: 't***@example.com' }, + ]; + + const first = signIn.__internal_future.emailCode.sendCode({ emailAddressId: 'email_123' }); + const second = signIn.__internal_future.emailCode.sendCode({ emailAddressId: 'email_123' }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + + deferred.resolve({ + client: null, + response: { id: 'signin_123' }, + }); + await Promise.all([first, second]); + }); + + it('coalesces concurrent second-factor preparations from the future API', async () => { + const deferred = createDeferredPromise(); + const mockFetch = vi.fn().mockReturnValue(deferred.promise); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ id: 'signin_123' } as any); + signIn.supportedSecondFactors = [ + { strategy: 'email_code', emailAddressId: 'email_123', safeIdentifier: 't***@example.com' }, + ]; + + const first = signIn.__internal_future.mfa.sendEmailCode(); + const second = signIn.__internal_future.mfa.sendEmailCode(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + + deferred.resolve({ + client: null, + response: { id: 'signin_123' }, + }); + await Promise.all([first, second]); + }); + + it('does not coalesce concurrent factor attempts', async () => { + const deferred = createDeferredPromise(); + const mockFetch = vi.fn().mockReturnValue(deferred.promise); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ id: 'signin_123' } as any); + const params = { strategy: 'email_code' as const, code: '123456' }; + + const first = signIn.attemptFirstFactor(params); + const second = signIn.attemptFirstFactor(params); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + deferred.resolve({ + client: null, + response: { id: 'signin_123' }, + }); + await Promise.all([first, second]); + }); + }); + describe('authenticateWithRedirect with an OAuth transport', () => { afterEach(() => { vi.clearAllMocks(); From 9419cb546ceb23828557a941bd1170ecf9074dd7 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 23 Jul 2026 13:52:24 -0400 Subject: [PATCH 02/14] fix(js): generalize prepare coalescing, key by resource id, cover sign-up --- .changeset/calm-codes-share.md | 2 +- packages/clerk-js/src/core/resources/Base.ts | 31 +++++++++++++- .../clerk-js/src/core/resources/SignIn.ts | 40 +++++------------- .../clerk-js/src/core/resources/SignUp.ts | 6 +++ .../core/resources/__tests__/SignIn.test.ts | 42 +++++++++++++++++++ 5 files changed, 89 insertions(+), 32 deletions(-) diff --git a/.changeset/calm-codes-share.md b/.changeset/calm-codes-share.md index edaf39b6542..d468dd65ce7 100644 --- a/.changeset/calm-codes-share.md +++ b/.changeset/calm-codes-share.md @@ -2,4 +2,4 @@ "@clerk/clerk-js": patch --- -Prevent overlapping sign-in factor preparations from triggering duplicate verification sends. +Prevent overlapping sign-in factor preparations and sign-up verification preparations from triggering duplicate verification sends. diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index 3fa1dacd6d4..f85150ce6ba 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -58,6 +58,14 @@ export abstract class BaseResource { id?: string; pathRoot = ''; + /** + * POST actions listed here are coalesced: while a request for an action with an identical body is + * in flight on this resource, subsequent identical calls return the pending promise instead of + * issuing a duplicate request. + */ + protected coalescedPostActions: readonly string[] = []; + #pendingCoalescedPosts = new Map>(); + static get fapiClient(): FapiClient { return BaseResource.clerk.getFapiClient(); } @@ -230,7 +238,28 @@ export abstract class BaseResource { } protected async _basePost(params: BaseMutateParams = {}): Promise { - return this._baseMutate({ ...params, method: 'POST' }); + if (!params.action || !this.coalescedPostActions.includes(params.action)) { + return this._baseMutate({ ...params, method: 'POST' }); + } + + const key = this.#coalescedPostKey(params); + const pending = this.#pendingCoalescedPosts.get(key); + if (pending) { + return pending; + } + + const post = this._baseMutate({ ...params, method: 'POST' }).finally(() => { + this.#pendingCoalescedPosts.delete(key); + }); + this.#pendingCoalescedPosts.set(key, post); + return post; + } + + #coalescedPostKey(params: BaseMutateParams): string { + const body = Object.entries(params.body ?? {}) + .filter(([, value]) => value !== undefined) + .sort(([left], [right]) => (left < right ? -1 : 1)); + return JSON.stringify([this.id, params.path, params.action, body]); } protected async _basePostBypass(params: BaseMutateParams = {}): Promise { diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 97510422ebe..64ac8ab2b69 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -98,17 +98,7 @@ import { clerkVerifyWeb3WalletCalledBeforeCreate, } from '../errors'; import { eventBus } from '../events'; -import { type BaseMutateParams, BaseResource, UserData, Verification } from './internal'; - -const isFactorPreparationAction = (action?: string): action is 'prepare_first_factor' | 'prepare_second_factor' => - action === 'prepare_first_factor' || action === 'prepare_second_factor'; - -const factorPreparationKey = (params: BaseMutateParams): string => { - const body = Object.entries(params.body ?? {}) - .filter(([, value]) => value !== undefined) - .sort(([left], [right]) => left.localeCompare(right)); - return JSON.stringify([params.path, params.action, body]); -}; +import { BaseResource, UserData, Verification } from './internal'; export class SignIn extends BaseResource implements SignInResource { pathRoot = '/client/sign_ins'; @@ -125,25 +115,7 @@ export class SignIn extends BaseResource implements SignInResource { userData: UserData = new UserData(null); clientTrustState?: ClientTrustState; protectCheck: ProtectCheckResource | null = null; - #pendingFactorPreparations = new Map>(); - - protected override _basePost(params: BaseMutateParams = {}): Promise { - if (!isFactorPreparationAction(params.action)) { - return super._basePost(params); - } - - const key = factorPreparationKey(params); - const pending = this.#pendingFactorPreparations.get(key); - if (pending) { - return pending; - } - - const preparation = super._basePost(params).finally(() => { - this.#pendingFactorPreparations.delete(key); - }); - this.#pendingFactorPreparations.set(key, preparation); - return preparation; - } + protected override coalescedPostActions: readonly string[] = ['prepare_first_factor', 'prepare_second_factor']; /** * The current status of the sign-in process. @@ -244,6 +216,10 @@ export class SignIn extends BaseResource implements SignInResource { }); }; + /** + * @remarks If an identical preparation for this sign-in is already in flight, its pending request is reused and no + * additional verification is sent. + */ prepareFirstFactor = (params: PrepareFirstFactorParams): Promise => { debugLogger.debug('SignIn.prepareFirstFactor', { id: this.id, strategy: params.strategy }); let config; @@ -380,6 +356,10 @@ export class SignIn extends BaseResource implements SignInResource { return { startEmailLinkFlow, cancelEmailLinkFlow: stop }; }; + /** + * @remarks If an identical preparation for this sign-in is already in flight, its pending request is reused and no + * additional verification is sent. + */ prepareSecondFactor = (params: PrepareSecondFactorParams): Promise => { debugLogger.debug('SignIn.prepareSecondFactor', { id: this.id, strategy: params.strategy }); return this._basePost({ diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 2a4ed453c3d..fd086512b9f 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -79,6 +79,8 @@ declare global { export class SignUp extends BaseResource implements SignUpResource { pathRoot = '/client/sign_ups'; + protected override coalescedPostActions: readonly string[] = ['prepare_verification']; + id: string | undefined; private _status: SignUpStatus | null = null; requiredFields: SignUpField[] = []; @@ -183,6 +185,10 @@ export class SignUp extends BaseResource implements SignUpResource { }); }; + /** + * @remarks If an identical preparation for this sign-up is already in flight, its pending request is reused and no + * additional verification is sent. + */ prepareVerification = (params: PrepareVerificationParams): Promise => { debugLogger.debug('SignUp.prepareVerification', { id: this.id, strategy: params.strategy }); return this._basePost({ 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 2a094daaded..5fdf3054609 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -5,6 +5,7 @@ import { eventBus } from '../../events'; import { signInErrorSignal, signInResourceSignal } from '../../signals'; import { BaseResource } from '../internal'; import { SignIn } from '../SignIn'; +import { SignUp } from '../SignUp'; // Mock the authenticateWithPopup module vi.mock('../../../utils/authenticateWithPopup', async () => { @@ -184,6 +185,47 @@ describe('SignIn', () => { await Promise.all([first, second]); }); + it('does not coalesce preparations across different sign-in attempts', async () => { + const deferred = createDeferredPromise(); + const mockFetch = vi.fn().mockReturnValue(deferred.promise); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ id: 'signin_123' } as any); + const params = { strategy: 'email_code' as const, emailAddressId: 'email_123' }; + + const first = signIn.prepareFirstFactor(params); + signIn.id = 'signin_456'; + const second = signIn.prepareFirstFactor(params); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + deferred.resolve({ + client: null, + response: { id: 'signin_456' }, + }); + await Promise.all([first, second]); + }); + + it('coalesces concurrent sign-up verification preparations', async () => { + const deferred = createDeferredPromise(); + const mockFetch = vi.fn().mockReturnValue(deferred.promise); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123' } as any); + const params = { strategy: 'email_code' as const }; + + const first = signUp.prepareVerification(params); + const second = signUp.prepareVerification(params); + + expect(mockFetch).toHaveBeenCalledTimes(1); + + deferred.resolve({ + client: null, + response: { id: 'signup_123' }, + }); + await Promise.all([first, second]); + }); + it('does not coalesce concurrent factor attempts', async () => { const deferred = createDeferredPromise(); const mockFetch = vi.fn().mockReturnValue(deferred.promise); From a19af240cd2d04d4dc2ed5686fb901f62549fad4 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 23 Jul 2026 13:57:57 -0400 Subject: [PATCH 03/14] chore: remove comments --- packages/clerk-js/src/core/resources/Base.ts | 5 ----- packages/clerk-js/src/core/resources/SignIn.ts | 8 -------- packages/clerk-js/src/core/resources/SignUp.ts | 4 ---- 3 files changed, 17 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index f85150ce6ba..421b24f40a5 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -58,11 +58,6 @@ export abstract class BaseResource { id?: string; pathRoot = ''; - /** - * POST actions listed here are coalesced: while a request for an action with an identical body is - * in flight on this resource, subsequent identical calls return the pending promise instead of - * issuing a duplicate request. - */ protected coalescedPostActions: readonly string[] = []; #pendingCoalescedPosts = new Map>(); diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 64ac8ab2b69..48592f13309 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -216,10 +216,6 @@ export class SignIn extends BaseResource implements SignInResource { }); }; - /** - * @remarks If an identical preparation for this sign-in is already in flight, its pending request is reused and no - * additional verification is sent. - */ prepareFirstFactor = (params: PrepareFirstFactorParams): Promise => { debugLogger.debug('SignIn.prepareFirstFactor', { id: this.id, strategy: params.strategy }); let config; @@ -356,10 +352,6 @@ export class SignIn extends BaseResource implements SignInResource { return { startEmailLinkFlow, cancelEmailLinkFlow: stop }; }; - /** - * @remarks If an identical preparation for this sign-in is already in flight, its pending request is reused and no - * additional verification is sent. - */ prepareSecondFactor = (params: PrepareSecondFactorParams): Promise => { debugLogger.debug('SignIn.prepareSecondFactor', { id: this.id, strategy: params.strategy }); return this._basePost({ diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index fd086512b9f..9afb9722abf 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -185,10 +185,6 @@ export class SignUp extends BaseResource implements SignUpResource { }); }; - /** - * @remarks If an identical preparation for this sign-up is already in flight, its pending request is reused and no - * additional verification is sent. - */ prepareVerification = (params: PrepareVerificationParams): Promise => { debugLogger.debug('SignUp.prepareVerification', { id: this.id, strategy: params.strategy }); return this._basePost({ From 7a411ca23d1128572ddf292164dc0acee82b3030 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 23 Jul 2026 14:05:51 -0400 Subject: [PATCH 04/14] fix(js): expire stalled coalesced preparations after a TTL --- packages/clerk-js/src/core/resources/Base.ts | 10 ++++++++- .../core/resources/__tests__/SignIn.test.ts | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index 421b24f40a5..ba947d41ea4 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -29,6 +29,8 @@ export type BaseMutateParams = { path?: string; }; +const COALESCED_POST_TTL_MS = 15_000; + function assertProductionKeysOnDev(statusCode: number, payloadErrors?: ClerkAPIErrorJSON[]) { if (!payloadErrors) { return; @@ -243,8 +245,14 @@ export abstract class BaseResource { return pending; } + // The TTL guards against a stalled request (no client-side fetch timeout) pinning the entry and + // silently swallowing retries such as "resend code" until page reload. + const expireTimer = setTimeout(() => this.#pendingCoalescedPosts.delete(key), COALESCED_POST_TTL_MS); const post = this._baseMutate({ ...params, method: 'POST' }).finally(() => { - this.#pendingCoalescedPosts.delete(key); + clearTimeout(expireTimer); + if (this.#pendingCoalescedPosts.get(key) === post) { + this.#pendingCoalescedPosts.delete(key); + } }); this.#pendingCoalescedPosts.set(key, post); return post; 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 5fdf3054609..1276739feac 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -185,6 +185,28 @@ describe('SignIn', () => { await Promise.all([first, second]); }); + it('allows a new preparation after a pending request stalls past the coalescing TTL', () => { + vi.useFakeTimers(); + try { + const mockFetch = vi.fn().mockReturnValue(new Promise(() => {})); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ id: 'signin_123' } as any); + const params = { strategy: 'email_code' as const, emailAddressId: 'email_123' }; + + void signIn.prepareFirstFactor(params); + void signIn.prepareFirstFactor(params); + expect(mockFetch).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(15_000); + + void signIn.prepareFirstFactor(params); + expect(mockFetch).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + it('does not coalesce preparations across different sign-in attempts', async () => { const deferred = createDeferredPromise(); const mockFetch = vi.fn().mockReturnValue(deferred.promise); From 6168ff18fd27ab43ccebd53968daea817fd9e066 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 23 Jul 2026 14:09:50 -0400 Subject: [PATCH 05/14] test(js): move sign-up prepare coalescing test into SignUp suite --- .../core/resources/__tests__/SignIn.test.ts | 21 --------------- .../core/resources/__tests__/SignUp.test.ts | 27 +++++++++++++++++++ 2 files changed, 27 insertions(+), 21 deletions(-) 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 1276739feac..e07bca94f57 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -5,7 +5,6 @@ import { eventBus } from '../../events'; import { signInErrorSignal, signInResourceSignal } from '../../signals'; import { BaseResource } from '../internal'; import { SignIn } from '../SignIn'; -import { SignUp } from '../SignUp'; // Mock the authenticateWithPopup module vi.mock('../../../utils/authenticateWithPopup', async () => { @@ -228,26 +227,6 @@ describe('SignIn', () => { await Promise.all([first, second]); }); - it('coalesces concurrent sign-up verification preparations', async () => { - const deferred = createDeferredPromise(); - const mockFetch = vi.fn().mockReturnValue(deferred.promise); - BaseResource._fetch = mockFetch; - - const signUp = new SignUp({ id: 'signup_123' } as any); - const params = { strategy: 'email_code' as const }; - - const first = signUp.prepareVerification(params); - const second = signUp.prepareVerification(params); - - expect(mockFetch).toHaveBeenCalledTimes(1); - - deferred.resolve({ - client: null, - response: { id: 'signup_123' }, - }); - await Promise.all([first, second]); - }); - it('does not coalesce concurrent factor attempts', async () => { const deferred = createDeferredPromise(); const mockFetch = vi.fn().mockReturnValue(deferred.promise); 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 a639c6244d8..a3f29e4b6d5 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -1,3 +1,4 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { eventBus } from '../../events'; @@ -37,6 +38,32 @@ describe('SignUp', () => { expect(snapshot).toBeDefined(); }); + describe('prepareVerification', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('coalesces concurrent identical preparations', async () => { + const deferred = createDeferredPromise(); + const mockFetch = vi.fn().mockReturnValue(deferred.promise); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123' } as any); + const params = { strategy: 'email_code' as const }; + + const first = signUp.prepareVerification(params); + const second = signUp.prepareVerification(params); + + expect(mockFetch).toHaveBeenCalledTimes(1); + + deferred.resolve({ + client: null, + response: { id: 'signup_123' }, + }); + await Promise.all([first, second]); + }); + }); + describe('authenticateWithRedirect with an OAuth transport', () => { afterEach(() => { vi.clearAllMocks(); From 1289a8803e116cae38ea9f4186c6622d1aa0c9db Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 23 Jul 2026 14:14:33 -0400 Subject: [PATCH 06/14] test(js): expand sign-up tests --- .../core/resources/__tests__/SignUp.test.ts | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) 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 a3f29e4b6d5..cc3bf6fa3db 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -62,6 +62,118 @@ describe('SignUp', () => { }); await Promise.all([first, second]); }); + + it('does not coalesce preparations for different verifications', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123' }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123' } as any); + + await Promise.all([ + signUp.prepareVerification({ strategy: 'email_code' }), + signUp.prepareVerification({ strategy: 'phone_code' }), + ]); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('starts a new preparation after the previous request succeeds', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123' }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123' } as any); + const params = { strategy: 'email_code' as const }; + + await signUp.prepareVerification(params); + await signUp.prepareVerification(params); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('starts a new preparation after the previous request fails', async () => { + const mockFetch = vi + .fn() + .mockRejectedValueOnce(new Error('prepare failed')) + .mockResolvedValueOnce({ + client: null, + response: { id: 'signup_123' }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123' } as any); + const params = { strategy: 'email_code' as const }; + + await expect(signUp.prepareVerification(params)).rejects.toThrow('prepare failed'); + await signUp.prepareVerification(params); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('does not coalesce preparations across different sign-up attempts', async () => { + const deferred = createDeferredPromise(); + const mockFetch = vi.fn().mockReturnValue(deferred.promise); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123' } as any); + const params = { strategy: 'email_code' as const }; + + const first = signUp.prepareVerification(params); + signUp.id = 'signup_456'; + const second = signUp.prepareVerification(params); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + deferred.resolve({ + client: null, + response: { id: 'signup_456' }, + }); + await Promise.all([first, second]); + }); + + it('coalesces concurrent preparations from the future API', async () => { + const deferred = createDeferredPromise(); + const mockFetch = vi.fn().mockReturnValue(deferred.promise); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123' } as any); + + const first = signUp.__internal_future.sendEmailCode(); + const second = signUp.__internal_future.sendEmailCode(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + + deferred.resolve({ + client: null, + response: { id: 'signup_123' }, + }); + await Promise.all([first, second]); + }); + + it('does not coalesce concurrent verification attempts', async () => { + const deferred = createDeferredPromise(); + const mockFetch = vi.fn().mockReturnValue(deferred.promise); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123' } as any); + const params = { strategy: 'email_code' as const, code: '123456' }; + + const first = signUp.attemptVerification(params); + const second = signUp.attemptVerification(params); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + deferred.resolve({ + client: null, + response: { id: 'signup_123' }, + }); + await Promise.all([first, second]); + }); }); describe('authenticateWithRedirect with an OAuth transport', () => { From de0d2001c15e79dded419860fe0ac932dac992b9 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 23 Jul 2026 14:26:38 -0400 Subject: [PATCH 07/14] fix(js): preserve resource serialization shape --- packages/clerk-js/src/core/resources/Base.ts | 6 +++++- packages/clerk-js/src/core/resources/SignIn.ts | 6 +++++- packages/clerk-js/src/core/resources/SignUp.ts | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index ba947d41ea4..4c192bd0bf5 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -30,6 +30,7 @@ export type BaseMutateParams = { }; const COALESCED_POST_TTL_MS = 15_000; +const COALESCED_POST_ACTIONS: readonly string[] = []; function assertProductionKeysOnDev(statusCode: number, payloadErrors?: ClerkAPIErrorJSON[]) { if (!payloadErrors) { @@ -60,9 +61,12 @@ export abstract class BaseResource { id?: string; pathRoot = ''; - protected coalescedPostActions: readonly string[] = []; #pendingCoalescedPosts = new Map>(); + protected get coalescedPostActions(): readonly string[] { + return COALESCED_POST_ACTIONS; + } + static get fapiClient(): FapiClient { return BaseResource.clerk.getFapiClient(); } diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 48592f13309..77b4f41cd52 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -100,6 +100,8 @@ import { import { eventBus } from '../events'; import { BaseResource, UserData, Verification } from './internal'; +const COALESCED_POST_ACTIONS: readonly string[] = ['prepare_first_factor', 'prepare_second_factor']; + export class SignIn extends BaseResource implements SignInResource { pathRoot = '/client/sign_ins'; @@ -115,7 +117,9 @@ export class SignIn extends BaseResource implements SignInResource { userData: UserData = new UserData(null); clientTrustState?: ClientTrustState; protectCheck: ProtectCheckResource | null = null; - protected override coalescedPostActions: readonly string[] = ['prepare_first_factor', 'prepare_second_factor']; + protected override get coalescedPostActions(): readonly string[] { + return COALESCED_POST_ACTIONS; + } /** * The current status of the sign-in process. diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 9afb9722abf..d05cf9d872b 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -76,10 +76,14 @@ declare global { } } +const COALESCED_POST_ACTIONS: readonly string[] = ['prepare_verification']; + export class SignUp extends BaseResource implements SignUpResource { pathRoot = '/client/sign_ups'; - protected override coalescedPostActions: readonly string[] = ['prepare_verification']; + protected override get coalescedPostActions(): readonly string[] { + return COALESCED_POST_ACTIONS; + } id: string | undefined; private _status: SignUpStatus | null = null; From 85cdd457d22fc18e73eaeecf88a6df90241e986d Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 23 Jul 2026 14:47:46 -0400 Subject: [PATCH 08/14] fix(js): abort coalesced factor preparations when the stall TTL expires --- packages/clerk-js/src/core/resources/Base.ts | 17 +++++++----- .../core/resources/__tests__/SignIn.test.ts | 26 ++++++++++++++++++- .../core/resources/__tests__/SignUp.test.ts | 3 +++ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index 4c192bd0bf5..4fb7097b0d4 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -27,9 +27,10 @@ export type BaseMutateParams = { body?: any; method?: HTTPMethod; path?: string; + signal?: AbortSignal; }; -const COALESCED_POST_TTL_MS = 15_000; +const COALESCED_POST_TTL_MS = 30_000; const COALESCED_POST_ACTIONS: readonly string[] = []; function assertProductionKeysOnDev(statusCode: number, payloadErrors?: ClerkAPIErrorJSON[]) { @@ -226,9 +227,9 @@ export abstract class BaseResource { } protected async _baseMutate(params: BaseMutateParams): Promise { - const { action, body, method, path } = params; + const { action, body, method, path, signal } = params; // TODO @userland-errors: - const json = await BaseResource._fetch({ method, path: path || this.path(action), body }); + const json = await BaseResource._fetch({ method, path: path || this.path(action), body, signal }); return this.fromJSON((json?.response || json) as J); } @@ -249,10 +250,12 @@ export abstract class BaseResource { return pending; } - // The TTL guards against a stalled request (no client-side fetch timeout) pinning the entry and - // silently swallowing retries such as "resend code" until page reload. - const expireTimer = setTimeout(() => this.#pendingCoalescedPosts.delete(key), COALESCED_POST_TTL_MS); - const post = this._baseMutate({ ...params, method: 'POST' }).finally(() => { + const controller = new AbortController(); + const expireTimer = setTimeout(() => { + this.#pendingCoalescedPosts.delete(key); + controller.abort(); + }, COALESCED_POST_TTL_MS); + const post = this._baseMutate({ ...params, method: 'POST', signal: controller.signal }).finally(() => { clearTimeout(expireTimer); if (this.#pendingCoalescedPosts.get(key) === post) { this.#pendingCoalescedPosts.delete(key); 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 e07bca94f57..3fa660d4c3e 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -197,10 +197,12 @@ describe('SignIn', () => { void signIn.prepareFirstFactor(params); expect(mockFetch).toHaveBeenCalledTimes(1); - vi.advanceTimersByTime(15_000); + vi.advanceTimersByTime(30_000); + expect(mockFetch.mock.calls[0][0].signal.aborted).toBe(true); void signIn.prepareFirstFactor(params); expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[1][0].signal.aborted).toBe(false); } finally { vi.useRealTimers(); } @@ -503,6 +505,7 @@ describe('SignIn', () => { expect(BaseResource._fetch).toHaveBeenLastCalledWith({ method: 'POST', path: '/client/sign_ins/test_id/prepare_first_factor', + signal: expect.any(AbortSignal), body: { emailAddressId: 'email_address_1', strategy: 'email_code', @@ -517,6 +520,7 @@ describe('SignIn', () => { expect(BaseResource._fetch).toHaveBeenLastCalledWith({ method: 'POST', path: '/client/sign_ins/test_id/prepare_first_factor', + signal: expect.any(AbortSignal), body: { emailAddressId: 'email_address_1', strategy: 'email_code', @@ -531,6 +535,7 @@ describe('SignIn', () => { expect(BaseResource._fetch).toHaveBeenLastCalledWith({ method: 'POST', path: '/client/sign_ins/test_id/prepare_first_factor', + signal: expect.any(AbortSignal), body: { emailAddressId: 'email_address_1', strategy: 'email_code', @@ -545,6 +550,7 @@ describe('SignIn', () => { expect(BaseResource._fetch).toHaveBeenLastCalledWith({ method: 'POST', path: '/client/sign_ins/test_id/prepare_first_factor', + signal: expect.any(AbortSignal), body: { emailAddressId: 'email_address_0', strategy: 'email_code', @@ -917,6 +923,7 @@ describe('SignIn', () => { expect(mockFetch).toHaveBeenNthCalledWith(2, { method: 'POST', path: '/client/sign_ins/signin_123/prepare_first_factor', + signal: expect.any(AbortSignal), body: { emailAddressId: 'email_123', strategy: 'email_code', @@ -951,6 +958,7 @@ describe('SignIn', () => { expect(mockFetch).toHaveBeenLastCalledWith({ method: 'POST', path: '/client/sign_ins/signin_123/prepare_first_factor', + signal: expect.any(AbortSignal), body: { emailAddressId: 'email_456', strategy: 'email_code', @@ -1023,6 +1031,7 @@ describe('SignIn', () => { expect(mockFetch).toHaveBeenNthCalledWith(2, { method: 'POST', path: '/client/sign_ins/signin_123/prepare_first_factor', + signal: expect.any(AbortSignal), body: { emailAddressId: 'email_123', redirectUrl: 'https://example.com/verify', @@ -1057,6 +1066,7 @@ describe('SignIn', () => { expect(mockFetch).toHaveBeenNthCalledWith(2, { method: 'POST', path: '/client/sign_ins/signin_123/prepare_first_factor', + signal: expect.any(AbortSignal), body: { emailAddressId: 'email_123', redirectUrl: 'https://other.com/verify', @@ -1094,6 +1104,7 @@ describe('SignIn', () => { expect(mockFetch).toHaveBeenLastCalledWith({ method: 'POST', path: '/client/sign_ins/signin_123/prepare_first_factor', + signal: expect.any(AbortSignal), body: { emailAddressId: 'email_456', redirectUrl: 'https://example.com/verify', @@ -1242,6 +1253,7 @@ describe('SignIn', () => { expect(mockFetch).toHaveBeenNthCalledWith(2, { method: 'POST', path: '/client/sign_ins/signin_123/prepare_first_factor', + signal: expect.any(AbortSignal), body: { phoneNumberId: 'phone_123', strategy: 'phone_code', @@ -1273,6 +1285,7 @@ describe('SignIn', () => { expect(mockFetch).toHaveBeenNthCalledWith(2, { method: 'POST', path: '/client/sign_ins/signin_123/prepare_first_factor', + signal: expect.any(AbortSignal), body: { phoneNumberId: 'phone_123', strategy: 'phone_code', @@ -1308,6 +1321,7 @@ describe('SignIn', () => { expect(mockFetch).toHaveBeenLastCalledWith({ method: 'POST', path: '/client/sign_ins/signin_123/prepare_first_factor', + signal: expect.any(AbortSignal), body: { phoneNumberId: 'phone_456', strategy: 'phone_code', @@ -1374,6 +1388,7 @@ describe('SignIn', () => { expect(mockFetch).toHaveBeenCalledWith({ method: 'POST', path: '/client/sign_ins/signin_123/prepare_first_factor', + signal: expect.any(AbortSignal), body: { emailAddressId: 'email_123', strategy: 'reset_password_email_code', @@ -1439,6 +1454,7 @@ describe('SignIn', () => { expect(mockFetch).toHaveBeenNthCalledWith(2, { method: 'POST', path: '/client/sign_ins/signin_123/prepare_first_factor', + signal: expect.any(AbortSignal), body: { phoneNumberId: 'phone_123', strategy: 'reset_password_phone_code', @@ -1468,6 +1484,7 @@ describe('SignIn', () => { expect(mockFetch).toHaveBeenCalledWith({ method: 'POST', path: '/client/sign_ins/signin_123/prepare_first_factor', + signal: expect.any(AbortSignal), body: { phoneNumberId: 'phone_123', strategy: 'reset_password_phone_code', @@ -1598,6 +1615,7 @@ describe('SignIn', () => { expect(mockFetch).toHaveBeenCalledWith({ method: 'POST', path: '/client/sign_ins/signin_123/prepare_second_factor', + signal: expect.any(AbortSignal), body: { phoneNumberId: 'phone_123', strategy: 'phone_code', @@ -1640,6 +1658,7 @@ describe('SignIn', () => { expect(mockFetch).toHaveBeenCalledWith({ method: 'POST', path: '/client/sign_ins/signin_123/prepare_second_factor', + signal: expect.any(AbortSignal), body: { emailAddressId: 'email_123', strategy: 'email_code', @@ -1716,6 +1735,7 @@ describe('SignIn', () => { expect.objectContaining({ method: 'POST', path: '/client/sign_ins/signin_123/prepare_first_factor', + signal: expect.any(AbortSignal), body: { strategy: 'passkey' }, }), ); @@ -2023,6 +2043,7 @@ describe('SignIn', () => { expect(mockFetch).toHaveBeenNthCalledWith(2, { method: 'POST', path: '/client/sign_ins/signin_123/prepare_first_factor', + signal: expect.any(AbortSignal), body: { web3WalletId: 'wallet_123', strategy: 'web3_metamask_signature', @@ -2355,6 +2376,7 @@ describe('SignIn', () => { expect(mockFetch).toHaveBeenNthCalledWith(2, { method: 'POST', path: '/client/sign_ins/signin_123/prepare_first_factor', + signal: expect.any(AbortSignal), body: { strategy: 'enterprise_sso', redirectUrl: 'https://example.com/sso-callback', @@ -2410,6 +2432,7 @@ describe('SignIn', () => { expect(mockFetch).toHaveBeenNthCalledWith(2, { method: 'POST', path: '/client/sign_ins/signin_ticket/prepare_first_factor', + signal: expect.any(AbortSignal), body: { strategy: 'enterprise_sso', redirectUrl: 'https://example.com/sso-callback', @@ -2516,6 +2539,7 @@ describe('SignIn', () => { expect(mockFetch).toHaveBeenNthCalledWith(1, { method: 'POST', path: '/client/sign_ins/signin_enterprise/prepare_first_factor', + signal: expect.any(AbortSignal), body: expect.objectContaining({ strategy: 'enterprise_sso', }), 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 cc3bf6fa3db..0bcff445b5f 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -623,6 +623,7 @@ describe('SignUp', () => { expect.objectContaining({ method: 'POST', path: '/client/sign_ups/signup_123/prepare_verification', + signal: expect.any(AbortSignal), body: expect.objectContaining({ strategy: 'phone_code', }), @@ -652,6 +653,7 @@ describe('SignUp', () => { expect(mockFetch).toHaveBeenCalledWith({ method: 'POST', path: '/client/sign_ups/signup_123/prepare_verification', + signal: expect.any(AbortSignal), body: { strategy: 'email_link', redirectUrl: 'https://example.com/verify', @@ -674,6 +676,7 @@ describe('SignUp', () => { expect(mockFetch).toHaveBeenCalledWith({ method: 'POST', path: '/client/sign_ups/signup_123/prepare_verification', + signal: expect.any(AbortSignal), body: { strategy: 'email_link', redirectUrl: 'https://other.com/verify', From b967c525a965aa8fa485c91c6f84164698a77c34 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 23 Jul 2026 14:52:35 -0400 Subject: [PATCH 09/14] fix(js): coalesce user profile verification preparations --- .changeset/calm-codes-share.md | 2 +- .../src/core/resources/EmailAddress.ts | 6 +++ .../src/core/resources/PhoneNumber.ts | 6 +++ .../clerk-js/src/core/resources/Web3Wallet.ts | 6 +++ .../resources/__tests__/EmailAddress.test.ts | 49 +++++++++++++++++++ .../resources/__tests__/Web3Wallet.test.ts | 1 + 6 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 packages/clerk-js/src/core/resources/__tests__/EmailAddress.test.ts diff --git a/.changeset/calm-codes-share.md b/.changeset/calm-codes-share.md index d468dd65ce7..dbe6ed42efd 100644 --- a/.changeset/calm-codes-share.md +++ b/.changeset/calm-codes-share.md @@ -2,4 +2,4 @@ "@clerk/clerk-js": patch --- -Prevent overlapping sign-in factor preparations and sign-up verification preparations from triggering duplicate verification sends. +Prevent overlapping verification preparations from triggering duplicate verification sends during sign-in, sign-up, and when verifying an email address, phone number, or web3 wallet from the user profile. diff --git a/packages/clerk-js/src/core/resources/EmailAddress.ts b/packages/clerk-js/src/core/resources/EmailAddress.ts index 796bfe37e16..fe7ff4dcd0d 100644 --- a/packages/clerk-js/src/core/resources/EmailAddress.ts +++ b/packages/clerk-js/src/core/resources/EmailAddress.ts @@ -15,7 +15,13 @@ import type { import { BaseResource, IdentificationLink, Verification } from './internal'; +const COALESCED_POST_ACTIONS: readonly string[] = ['prepare_verification']; + export class EmailAddress extends BaseResource implements EmailAddressResource { + protected override get coalescedPostActions(): readonly string[] { + return COALESCED_POST_ACTIONS; + } + id!: string; emailAddress = ''; matchesSsoConnection = false; diff --git a/packages/clerk-js/src/core/resources/PhoneNumber.ts b/packages/clerk-js/src/core/resources/PhoneNumber.ts index 809cf27c807..1e4827172ee 100644 --- a/packages/clerk-js/src/core/resources/PhoneNumber.ts +++ b/packages/clerk-js/src/core/resources/PhoneNumber.ts @@ -10,7 +10,13 @@ import type { import { BaseResource, IdentificationLink, Verification } from './internal'; +const COALESCED_POST_ACTIONS: readonly string[] = ['prepare_verification']; + export class PhoneNumber extends BaseResource implements PhoneNumberResource { + protected override get coalescedPostActions(): readonly string[] { + return COALESCED_POST_ACTIONS; + } + id!: string; phoneNumber = ''; reservedForSecondFactor = false; diff --git a/packages/clerk-js/src/core/resources/Web3Wallet.ts b/packages/clerk-js/src/core/resources/Web3Wallet.ts index 1ee299bde20..47b40b19cba 100644 --- a/packages/clerk-js/src/core/resources/Web3Wallet.ts +++ b/packages/clerk-js/src/core/resources/Web3Wallet.ts @@ -9,7 +9,13 @@ import type { import { BaseResource, Verification } from './internal'; +const COALESCED_POST_ACTIONS: readonly string[] = ['prepare_verification']; + export class Web3Wallet extends BaseResource implements Web3WalletResource { + protected override get coalescedPostActions(): readonly string[] { + return COALESCED_POST_ACTIONS; + } + id!: string; web3Wallet = ''; verification!: VerificationResource; diff --git a/packages/clerk-js/src/core/resources/__tests__/EmailAddress.test.ts b/packages/clerk-js/src/core/resources/__tests__/EmailAddress.test.ts new file mode 100644 index 00000000000..64cfc2cd2e7 --- /dev/null +++ b/packages/clerk-js/src/core/resources/__tests__/EmailAddress.test.ts @@ -0,0 +1,49 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; +import type { EmailAddressJSON } from '@clerk/shared/types'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { BaseResource, EmailAddress } from '../internal'; + +describe('EmailAddress', () => { + describe('prepareVerification', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('coalesces concurrent identical preparations', async () => { + const deferred = createDeferredPromise(); + const mockFetch = vi.fn().mockReturnValue(deferred.promise); + // @ts-ignore + BaseResource._fetch = mockFetch; + + const emailAddress = new EmailAddress({ id: 'email_123' } as EmailAddressJSON, '/me/email_addresses'); + const params = { strategy: 'email_code' as const }; + + const first = emailAddress.prepareVerification(params); + const second = emailAddress.prepareVerification(params); + + expect(mockFetch).toHaveBeenCalledTimes(1); + + deferred.resolve({ + response: { id: 'email_123' }, + }); + await Promise.all([first, second]); + }); + + it('starts a new preparation after the previous request succeeds', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + response: { id: 'email_123' }, + }); + // @ts-ignore + BaseResource._fetch = mockFetch; + + const emailAddress = new EmailAddress({ id: 'email_123' } as EmailAddressJSON, '/me/email_addresses'); + const params = { strategy: 'email_code' as const }; + + await emailAddress.prepareVerification(params); + await emailAddress.prepareVerification(params); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/clerk-js/src/core/resources/__tests__/Web3Wallet.test.ts b/packages/clerk-js/src/core/resources/__tests__/Web3Wallet.test.ts index bb29fde3ad8..95ff19f8ab3 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Web3Wallet.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Web3Wallet.test.ts @@ -46,6 +46,7 @@ describe('Web3 wallet', () => { body: { strategy: 'web3_metamask_signature', }, + signal: expect.any(AbortSignal), }); }); From be5aa805884fb70d3caadb0733596c217c84c595 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 23 Jul 2026 14:56:07 -0400 Subject: [PATCH 10/14] perf(js): lazily track coalesced preparations without timers --- packages/clerk-js/src/core/resources/Base.ts | 25 ++++++++----------- .../core/resources/__tests__/SignIn.test.ts | 2 +- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index 4fb7097b0d4..66bad4e8dbf 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -62,7 +62,7 @@ export abstract class BaseResource { id?: string; pathRoot = ''; - #pendingCoalescedPosts = new Map>(); + #pendingCoalescedPosts?: Map; controller: AbortController; expiresAt: number }>; protected get coalescedPostActions(): readonly string[] { return COALESCED_POST_ACTIONS; @@ -245,24 +245,21 @@ export abstract class BaseResource { } const key = this.#coalescedPostKey(params); - const pending = this.#pendingCoalescedPosts.get(key); - if (pending) { - return pending; + const posts = (this.#pendingCoalescedPosts ??= new Map()); + const pending = posts.get(key); + if (pending && Date.now() < pending.expiresAt) { + return pending.promise; } + pending?.controller.abort(); const controller = new AbortController(); - const expireTimer = setTimeout(() => { - this.#pendingCoalescedPosts.delete(key); - controller.abort(); - }, COALESCED_POST_TTL_MS); - const post = this._baseMutate({ ...params, method: 'POST', signal: controller.signal }).finally(() => { - clearTimeout(expireTimer); - if (this.#pendingCoalescedPosts.get(key) === post) { - this.#pendingCoalescedPosts.delete(key); + const promise = this._baseMutate({ ...params, method: 'POST', signal: controller.signal }).finally(() => { + if (posts.get(key)?.promise === promise) { + posts.delete(key); } }); - this.#pendingCoalescedPosts.set(key, post); - return post; + posts.set(key, { promise, controller, expiresAt: Date.now() + COALESCED_POST_TTL_MS }); + return promise; } #coalescedPostKey(params: BaseMutateParams): string { 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 3fa660d4c3e..576983ea2c0 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -198,10 +198,10 @@ describe('SignIn', () => { expect(mockFetch).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(30_000); - expect(mockFetch.mock.calls[0][0].signal.aborted).toBe(true); void signIn.prepareFirstFactor(params); expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0][0].signal.aborted).toBe(true); expect(mockFetch.mock.calls[1][0].signal.aborted).toBe(false); } finally { vi.useRealTimers(); From 5df1f92005e323d03aceb0a6e939b89574f43ef5 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 23 Jul 2026 14:59:57 -0400 Subject: [PATCH 11/14] refactor(js): opt into preparation coalescing per call site --- packages/clerk-js/src/core/resources/Base.ts | 8 ++------ .../clerk-js/src/core/resources/EmailAddress.ts | 7 +------ .../clerk-js/src/core/resources/PhoneNumber.ts | 7 +------ packages/clerk-js/src/core/resources/SignIn.ts | 17 ++++++++++++----- packages/clerk-js/src/core/resources/SignUp.ts | 11 +++++------ .../clerk-js/src/core/resources/Web3Wallet.ts | 7 +------ 6 files changed, 22 insertions(+), 35 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index 66bad4e8dbf..291dc7401ea 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -25,13 +25,13 @@ export type BaseFetchOptions = ClerkResourceReloadParams & { export type BaseMutateParams = { action?: string; body?: any; + coalesce?: boolean; method?: HTTPMethod; path?: string; signal?: AbortSignal; }; const COALESCED_POST_TTL_MS = 30_000; -const COALESCED_POST_ACTIONS: readonly string[] = []; function assertProductionKeysOnDev(statusCode: number, payloadErrors?: ClerkAPIErrorJSON[]) { if (!payloadErrors) { @@ -64,10 +64,6 @@ export abstract class BaseResource { #pendingCoalescedPosts?: Map; controller: AbortController; expiresAt: number }>; - protected get coalescedPostActions(): readonly string[] { - return COALESCED_POST_ACTIONS; - } - static get fapiClient(): FapiClient { return BaseResource.clerk.getFapiClient(); } @@ -240,7 +236,7 @@ export abstract class BaseResource { } protected async _basePost(params: BaseMutateParams = {}): Promise { - if (!params.action || !this.coalescedPostActions.includes(params.action)) { + if (!params.coalesce) { return this._baseMutate({ ...params, method: 'POST' }); } diff --git a/packages/clerk-js/src/core/resources/EmailAddress.ts b/packages/clerk-js/src/core/resources/EmailAddress.ts index fe7ff4dcd0d..1d4df3c6b73 100644 --- a/packages/clerk-js/src/core/resources/EmailAddress.ts +++ b/packages/clerk-js/src/core/resources/EmailAddress.ts @@ -15,13 +15,7 @@ import type { import { BaseResource, IdentificationLink, Verification } from './internal'; -const COALESCED_POST_ACTIONS: readonly string[] = ['prepare_verification']; - export class EmailAddress extends BaseResource implements EmailAddressResource { - protected override get coalescedPostActions(): readonly string[] { - return COALESCED_POST_ACTIONS; - } - id!: string; emailAddress = ''; matchesSsoConnection = false; @@ -44,6 +38,7 @@ export class EmailAddress extends BaseResource implements EmailAddressResource { prepareVerification = (params: PrepareEmailAddressVerificationParams): Promise => { return this._basePost({ action: 'prepare_verification', + coalesce: true, body: { ...params }, }); }; diff --git a/packages/clerk-js/src/core/resources/PhoneNumber.ts b/packages/clerk-js/src/core/resources/PhoneNumber.ts index 1e4827172ee..2b009c705f3 100644 --- a/packages/clerk-js/src/core/resources/PhoneNumber.ts +++ b/packages/clerk-js/src/core/resources/PhoneNumber.ts @@ -10,13 +10,7 @@ import type { import { BaseResource, IdentificationLink, Verification } from './internal'; -const COALESCED_POST_ACTIONS: readonly string[] = ['prepare_verification']; - export class PhoneNumber extends BaseResource implements PhoneNumberResource { - protected override get coalescedPostActions(): readonly string[] { - return COALESCED_POST_ACTIONS; - } - id!: string; phoneNumber = ''; reservedForSecondFactor = false; @@ -40,6 +34,7 @@ export class PhoneNumber extends BaseResource implements PhoneNumberResource { prepareVerification = (): Promise => { return this._basePost({ action: 'prepare_verification', + coalesce: true, body: { strategy: 'phone_code' }, }); }; diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 77b4f41cd52..20b206091f4 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -100,8 +100,6 @@ import { import { eventBus } from '../events'; import { BaseResource, UserData, Verification } from './internal'; -const COALESCED_POST_ACTIONS: readonly string[] = ['prepare_first_factor', 'prepare_second_factor']; - export class SignIn extends BaseResource implements SignInResource { pathRoot = '/client/sign_ins'; @@ -117,9 +115,6 @@ export class SignIn extends BaseResource implements SignInResource { userData: UserData = new UserData(null); clientTrustState?: ClientTrustState; protectCheck: ProtectCheckResource | null = null; - protected override get coalescedPostActions(): readonly string[] { - return COALESCED_POST_ACTIONS; - } /** * The current status of the sign-in process. @@ -270,6 +265,7 @@ export class SignIn extends BaseResource implements SignInResource { return this._basePost({ body: { ...config, strategy: params.strategy }, action: 'prepare_first_factor', + coalesce: true, }); }; @@ -361,6 +357,7 @@ export class SignIn extends BaseResource implements SignInResource { return this._basePost({ body: params, action: 'prepare_second_factor', + coalesce: true, }); }; @@ -888,6 +885,7 @@ class SignInFuture implements SignInFutureResource { await this.#resource.__internal_basePost({ body: { emailAddressId, strategy: 'reset_password_email_code' }, action: 'prepare_first_factor', + coalesce: true, }); }); } @@ -931,6 +929,7 @@ class SignInFuture implements SignInFutureResource { await this.#resource.__internal_basePost({ body: { phoneNumberId, strategy: 'reset_password_phone_code' }, action: 'prepare_first_factor', + coalesce: true, }); }); } @@ -1088,6 +1087,7 @@ class SignInFuture implements SignInFutureResource { await this.#resource.__internal_basePost({ body: { emailAddressId: emailCodeFactor.emailAddressId, strategy: 'email_code' }, action: 'prepare_first_factor', + coalesce: true, }); }); } @@ -1140,6 +1140,7 @@ class SignInFuture implements SignInFutureResource { strategy: 'email_link', }, action: 'prepare_first_factor', + coalesce: true, }); }); } @@ -1192,6 +1193,7 @@ class SignInFuture implements SignInFutureResource { await this.#resource.__internal_basePost({ body: { phoneNumberId: phoneCodeFactor.phoneNumberId, strategy: 'phone_code', channel }, action: 'prepare_first_factor', + coalesce: true, }); }); } @@ -1257,6 +1259,7 @@ class SignInFuture implements SignInFutureResource { strategy: 'enterprise_sso', }, action: 'prepare_first_factor', + coalesce: true, }); } @@ -1324,6 +1327,7 @@ class SignInFuture implements SignInFutureResource { await this.#resource.__internal_basePost({ body: { web3WalletId: web3FirstFactor.web3WalletId, strategy }, action: 'prepare_first_factor', + coalesce: true, }); const { message } = this.firstFactorVerification; @@ -1395,6 +1399,7 @@ class SignInFuture implements SignInFutureResource { await this.#resource.__internal_basePost({ body: { strategy: 'passkey' }, action: 'prepare_first_factor', + coalesce: true, }); } @@ -1447,6 +1452,7 @@ class SignInFuture implements SignInFutureResource { await this.#resource.__internal_basePost({ body: { phoneNumberId, strategy: 'phone_code' }, action: 'prepare_second_factor', + coalesce: true, }); }); } @@ -1473,6 +1479,7 @@ class SignInFuture implements SignInFutureResource { await this.#resource.__internal_basePost({ body: { emailAddressId, strategy: 'email_code' }, action: 'prepare_second_factor', + coalesce: true, }); }); } diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index d05cf9d872b..32f8e625239 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -76,15 +76,9 @@ declare global { } } -const COALESCED_POST_ACTIONS: readonly string[] = ['prepare_verification']; - export class SignUp extends BaseResource implements SignUpResource { pathRoot = '/client/sign_ups'; - protected override get coalescedPostActions(): readonly string[] { - return COALESCED_POST_ACTIONS; - } - id: string | undefined; private _status: SignUpStatus | null = null; requiredFields: SignUpField[] = []; @@ -194,6 +188,7 @@ export class SignUp extends BaseResource implements SignUpResource { return this._basePost({ body: params, action: 'prepare_verification', + coalesce: true, }); }; @@ -979,6 +974,7 @@ class SignUpFuture implements SignUpFutureResource { await this.#resource.__internal_basePost({ body: { strategy: 'email_code' }, action: 'prepare_verification', + coalesce: true, }); }); } @@ -999,6 +995,7 @@ class SignUpFuture implements SignUpFutureResource { await this.#resource.__internal_basePost({ body: { strategy: 'phone_code', channel }, action: 'prepare_verification', + coalesce: true, }); }); } @@ -1026,6 +1023,7 @@ class SignUpFuture implements SignUpFutureResource { await this.#resource.__internal_basePost({ body: { strategy: 'email_link', redirectUrl: absoluteVerificationUrl }, action: 'prepare_verification', + coalesce: true, }); }); } @@ -1169,6 +1167,7 @@ class SignUpFuture implements SignUpFutureResource { await this.#resource.__internal_basePost({ body: { strategy }, action: 'prepare_verification', + coalesce: true, }); const { message } = this.#resource.verifications.web3Wallet; diff --git a/packages/clerk-js/src/core/resources/Web3Wallet.ts b/packages/clerk-js/src/core/resources/Web3Wallet.ts index 47b40b19cba..cd1a923ea37 100644 --- a/packages/clerk-js/src/core/resources/Web3Wallet.ts +++ b/packages/clerk-js/src/core/resources/Web3Wallet.ts @@ -9,13 +9,7 @@ import type { import { BaseResource, Verification } from './internal'; -const COALESCED_POST_ACTIONS: readonly string[] = ['prepare_verification']; - export class Web3Wallet extends BaseResource implements Web3WalletResource { - protected override get coalescedPostActions(): readonly string[] { - return COALESCED_POST_ACTIONS; - } - id!: string; web3Wallet = ''; verification!: VerificationResource; @@ -36,6 +30,7 @@ export class Web3Wallet extends BaseResource implements Web3WalletResource { prepareVerification = (params: PrepareWeb3WalletVerificationParams): Promise => { return this._basePost({ action: 'prepare_verification', + coalesce: true, body: { ...params }, }); }; From 59bf726f1fe9bef951f12d312f4d0391612cf410 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 23 Jul 2026 15:11:19 -0400 Subject: [PATCH 12/14] fix(js): resolve coalescing build checks --- packages/clerk-js/src/core/resources/Base.ts | 8 +++++++- .../src/core/resources/__tests__/EmailAddress.test.ts | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index 291dc7401ea..195ad2f7d9d 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -33,6 +33,12 @@ export type BaseMutateParams = { const COALESCED_POST_TTL_MS = 30_000; +type PendingCoalescedPost = { + promise: Promise; + controller: AbortController; + expiresAt: number; +}; + function assertProductionKeysOnDev(statusCode: number, payloadErrors?: ClerkAPIErrorJSON[]) { if (!payloadErrors) { return; @@ -62,7 +68,7 @@ export abstract class BaseResource { id?: string; pathRoot = ''; - #pendingCoalescedPosts?: Map; controller: AbortController; expiresAt: number }>; + #pendingCoalescedPosts?: Map>; static get fapiClient(): FapiClient { return BaseResource.clerk.getFapiClient(); diff --git a/packages/clerk-js/src/core/resources/__tests__/EmailAddress.test.ts b/packages/clerk-js/src/core/resources/__tests__/EmailAddress.test.ts index 64cfc2cd2e7..8fe7c6d6772 100644 --- a/packages/clerk-js/src/core/resources/__tests__/EmailAddress.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/EmailAddress.test.ts @@ -1,5 +1,5 @@ -import { createDeferredPromise } from '@clerk/shared/utils'; import type { EmailAddressJSON } from '@clerk/shared/types'; +import { createDeferredPromise } from '@clerk/shared/utils'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { BaseResource, EmailAddress } from '../internal'; From c4b41371c87f70faecda19b0788b51c21beac47c Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 23 Jul 2026 15:30:07 -0400 Subject: [PATCH 13/14] fix(js): preserve expired preparation requests --- packages/clerk-js/src/core/resources/Base.ts | 1 - .../core/resources/__tests__/SignIn.test.ts | 32 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index 195ad2f7d9d..4ad63d5d01c 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -252,7 +252,6 @@ export abstract class BaseResource { if (pending && Date.now() < pending.expiresAt) { return pending.promise; } - pending?.controller.abort(); const controller = new AbortController(); const promise = this._baseMutate({ ...params, method: 'POST', signal: controller.signal }).finally(() => { 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 576983ea2c0..236b62da151 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -184,25 +184,45 @@ describe('SignIn', () => { await Promise.all([first, second]); }); - it('allows a new preparation after a pending request stalls past the coalescing TTL', () => { + it('allows a new preparation after the TTL without aborting the pending request', async () => { vi.useFakeTimers(); try { - const mockFetch = vi.fn().mockReturnValue(new Promise(() => {})); + const firstDeferred = createDeferredPromise(); + const secondDeferred = createDeferredPromise(); + const mockFetch = vi + .fn() + .mockReturnValueOnce(firstDeferred.promise) + .mockReturnValueOnce(secondDeferred.promise); BaseResource._fetch = mockFetch; const signIn = new SignIn({ id: 'signin_123' } as any); const params = { strategy: 'email_code' as const, emailAddressId: 'email_123' }; - void signIn.prepareFirstFactor(params); - void signIn.prepareFirstFactor(params); + const first = signIn.prepareFirstFactor(params); + const duplicate = signIn.prepareFirstFactor(params); expect(mockFetch).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(30_000); - void signIn.prepareFirstFactor(params); + const replacement = signIn.prepareFirstFactor(params); expect(mockFetch).toHaveBeenCalledTimes(2); - expect(mockFetch.mock.calls[0][0].signal.aborted).toBe(true); + expect(mockFetch.mock.calls[0][0].signal.aborted).toBe(false); expect(mockFetch.mock.calls[1][0].signal.aborted).toBe(false); + + firstDeferred.resolve({ + client: null, + response: { id: 'signin_123' }, + }); + await Promise.all([first, duplicate]); + + const replacementDuplicate = signIn.prepareFirstFactor(params); + expect(mockFetch).toHaveBeenCalledTimes(2); + + secondDeferred.resolve({ + client: null, + response: { id: 'signin_123' }, + }); + await Promise.all([replacement, replacementDuplicate]); } finally { vi.useRealTimers(); } From 30e121c4ebed96656aa752f2d5f6dd458aad92f9 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 23 Jul 2026 15:33:52 -0400 Subject: [PATCH 14/14] test(js): expand verification coalescing coverage --- .../resources/__tests__/EmailAddress.test.ts | 74 +++++++++++++++++++ .../resources/__tests__/Web3Wallet.test.ts | 16 +++- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/packages/clerk-js/src/core/resources/__tests__/EmailAddress.test.ts b/packages/clerk-js/src/core/resources/__tests__/EmailAddress.test.ts index 8fe7c6d6772..8043aba70d8 100644 --- a/packages/clerk-js/src/core/resources/__tests__/EmailAddress.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/EmailAddress.test.ts @@ -30,6 +30,23 @@ describe('EmailAddress', () => { await Promise.all([first, second]); }); + it('does not coalesce preparations with different strategies or params', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + response: { id: 'email_123' }, + }); + BaseResource._fetch = mockFetch; + + const emailAddress = new EmailAddress({ id: 'email_123' } as EmailAddressJSON, '/me/email_addresses'); + + await Promise.all([ + emailAddress.prepareVerification({ strategy: 'email_code' }), + emailAddress.prepareVerification({ strategy: 'email_link', redirectUrl: '/verify-one' }), + emailAddress.prepareVerification({ strategy: 'email_link', redirectUrl: '/verify-two' }), + ]); + + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + it('starts a new preparation after the previous request succeeds', async () => { const mockFetch = vi.fn().mockResolvedValue({ response: { id: 'email_123' }, @@ -45,5 +62,62 @@ describe('EmailAddress', () => { expect(mockFetch).toHaveBeenCalledTimes(2); }); + + it('starts a new preparation after the previous request fails', async () => { + const mockFetch = vi + .fn() + .mockRejectedValueOnce(new Error('prepare failed')) + .mockResolvedValueOnce({ + response: { id: 'email_123' }, + }); + BaseResource._fetch = mockFetch; + + const emailAddress = new EmailAddress({ id: 'email_123' } as EmailAddressJSON, '/me/email_addresses'); + const params = { strategy: 'email_code' as const }; + + await expect(emailAddress.prepareVerification(params)).rejects.toThrow('prepare failed'); + await emailAddress.prepareVerification(params); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('does not coalesce preparations across EmailAddress instances', async () => { + const deferred = createDeferredPromise(); + const mockFetch = vi.fn().mockReturnValue(deferred.promise); + BaseResource._fetch = mockFetch; + + const firstEmailAddress = new EmailAddress({ id: 'email_123' } as EmailAddressJSON, '/me/email_addresses'); + const secondEmailAddress = new EmailAddress({ id: 'email_123' } as EmailAddressJSON, '/me/email_addresses'); + const params = { strategy: 'email_code' as const }; + + const first = firstEmailAddress.prepareVerification(params); + const second = secondEmailAddress.prepareVerification(params); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + deferred.resolve({ + response: { id: 'email_123' }, + }); + await Promise.all([first, second]); + }); + + it('does not coalesce concurrent verification attempts', async () => { + const deferred = createDeferredPromise(); + const mockFetch = vi.fn().mockReturnValue(deferred.promise); + BaseResource._fetch = mockFetch; + + const emailAddress = new EmailAddress({ id: 'email_123' } as EmailAddressJSON, '/me/email_addresses'); + const params = { code: '123456' }; + + const first = emailAddress.attemptVerification(params); + const second = emailAddress.attemptVerification(params); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + deferred.resolve({ + response: { id: 'email_123' }, + }); + await Promise.all([first, second]); + }); }); }); diff --git a/packages/clerk-js/src/core/resources/__tests__/Web3Wallet.test.ts b/packages/clerk-js/src/core/resources/__tests__/Web3Wallet.test.ts index 95ff19f8ab3..62ca2c90393 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Web3Wallet.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Web3Wallet.test.ts @@ -1,4 +1,5 @@ import type { Web3WalletJSON } from '@clerk/shared/types'; +import { createDeferredPromise } from '@clerk/shared/utils'; import { describe, expect, it, vi } from 'vitest'; import { BaseResource, Web3Wallet } from '../internal'; @@ -33,14 +34,18 @@ describe('Web3 wallet', () => { web3_wallet: '0x0000000000000000000000000000000000000000', } as Web3WalletJSON; + const deferred = createDeferredPromise(); + const mockFetch = vi.fn().mockReturnValue(deferred.promise); // @ts-ignore - BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: web3WalletJSON })); + BaseResource._fetch = mockFetch; const web3Wallet = new Web3Wallet(web3WalletJSON, '/me/web3_wallets'); - await web3Wallet.prepareVerification({ strategy: 'web3_metamask_signature' }); + const params = { strategy: 'web3_metamask_signature' as const }; + const first = web3Wallet.prepareVerification(params); + const second = web3Wallet.prepareVerification(params); - // @ts-ignore - expect(BaseResource._fetch).toHaveBeenCalledWith({ + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith({ method: 'POST', path: `/me/web3_wallets/${web3WalletJSON.id}/prepare_verification`, body: { @@ -48,6 +53,9 @@ describe('Web3 wallet', () => { }, signal: expect.any(AbortSignal), }); + + deferred.resolve({ response: web3WalletJSON }); + await Promise.all([first, second]); }); it('attemptVerification', async () => {