From 7bbce8784edc9f8a87a5ac0a13e0d4f28da3b841 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 29 Oct 2026 19:01:20 -0400 Subject: [PATCH] fix(clerk-js): stop email link polling after the flow is cancelled --- .../email-link-polling-stops-on-cancel.md | 6 ++ .../src/core/resources/EmailAddress.ts | 24 ++++++- .../clerk-js/src/core/resources/SignIn.ts | 12 +++- .../clerk-js/src/core/resources/SignUp.ts | 12 +++- .../resources/__tests__/EmailAddress.test.ts | 56 ++++++++++++++++ .../core/resources/__tests__/SignIn.test.ts | 40 ++++++++++++ .../core/resources/__tests__/SignUp.test.ts | 44 +++++++++++++ ...ser-profile-email-section.feature.test.tsx | 17 +++++ packages/shared/src/__tests__/poller.spec.ts | 65 +++++++++++++++++++ packages/shared/src/poller.ts | 9 ++- 10 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 .changeset/email-link-polling-stops-on-cancel.md create mode 100644 packages/shared/src/__tests__/poller.spec.ts diff --git a/.changeset/email-link-polling-stops-on-cancel.md b/.changeset/email-link-polling-stops-on-cancel.md new file mode 100644 index 00000000000..4ce72a4207e --- /dev/null +++ b/.changeset/email-link-polling-stops-on-cancel.md @@ -0,0 +1,6 @@ +--- +'@clerk/shared': patch +'@clerk/clerk-js': patch +--- + +Fix email link and enterprise SSO link verification continuing to check for the link after it was cancelled, for example when the user leaves the verification screen. Restarting a cancelled flow no longer leaves a second check running. diff --git a/packages/clerk-js/src/core/resources/EmailAddress.ts b/packages/clerk-js/src/core/resources/EmailAddress.ts index 1d4df3c6b73..5951c384f90 100644 --- a/packages/clerk-js/src/core/resources/EmailAddress.ts +++ b/packages/clerk-js/src/core/resources/EmailAddress.ts @@ -53,13 +53,18 @@ export class EmailAddress extends BaseResource implements EmailAddressResource { createEmailLinkFlow = (): CreateEmailLinkFlowReturn => { const { run, stop } = Poller(); + let attempt = 0; const startEmailLinkFlow = async ({ redirectUrl }: StartEmailLinkFlowParams): Promise => { + const current = ++attempt; await this.prepareVerification({ strategy: 'email_link', redirectUrl: redirectUrl, }); return new Promise((resolve, reject) => { + if (current !== attempt) { + return; + } void run(() => { return this.reload() .then(res => { @@ -75,7 +80,12 @@ export class EmailAddress extends BaseResource implements EmailAddressResource { }); }); }; - return { startEmailLinkFlow, cancelEmailLinkFlow: stop }; + const cancelEmailLinkFlow = () => { + attempt++; + stop(); + }; + + return { startEmailLinkFlow, cancelEmailLinkFlow }; }; createEnterpriseSSOLinkFlow = (): CreateEnterpriseSSOLinkFlowReturn< @@ -83,10 +93,12 @@ export class EmailAddress extends BaseResource implements EmailAddressResource { EmailAddressResource > => { const { run, stop } = Poller(); + let attempt = 0; const startEnterpriseSSOLinkFlow = async ({ redirectUrl, }: StartEnterpriseSSOLinkFlowParams): Promise => { + const current = ++attempt; const response = await this.prepareVerification({ strategy: 'enterprise_sso', redirectUrl, @@ -95,6 +107,9 @@ export class EmailAddress extends BaseResource implements EmailAddressResource { throw Error('Unexpected: External verification redirect URL is missing'); } return new Promise((resolve, reject) => { + if (current !== attempt) { + return; + } void run(() => { return this.reload() .then(res => { @@ -110,7 +125,12 @@ export class EmailAddress extends BaseResource implements EmailAddressResource { }); }); }; - return { startEnterpriseSSOLinkFlow, cancelEnterpriseSSOLinkFlow: stop }; + const cancelEnterpriseSSOLinkFlow = () => { + attempt++; + stop(); + }; + + return { startEnterpriseSSOLinkFlow, cancelEnterpriseSSOLinkFlow }; }; destroy = (): Promise => this._baseDelete(); diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 83caee52941..c9d4a93a5ab 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -346,11 +346,13 @@ export class SignIn extends BaseResource implements SignInResource { createEmailLinkFlow = (): CreateEmailLinkFlowReturn => { const { run, stop } = Poller(); + let attempt = 0; const startEmailLinkFlow = async ({ emailAddressId, redirectUrl, }: SignInStartEmailLinkFlowParams): Promise => { + const current = ++attempt; if (!this.id) { clerkVerifyEmailAddressCalledBeforeCreate('SignIn'); } @@ -372,6 +374,9 @@ export class SignIn extends BaseResource implements SignInResource { } return new Promise((resolve, reject) => { + if (current !== attempt) { + return; + } void run(() => { return this.reload() .then(res => { @@ -388,7 +393,12 @@ export class SignIn extends BaseResource implements SignInResource { }); }; - return { startEmailLinkFlow, cancelEmailLinkFlow: stop }; + const cancelEmailLinkFlow = () => { + attempt++; + stop(); + }; + + return { startEmailLinkFlow, cancelEmailLinkFlow }; }; prepareSecondFactor = (params: PrepareSecondFactorParams): Promise => { diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index b335d7cfe14..47e0cf2b1db 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -254,8 +254,10 @@ export class SignUp extends BaseResource implements SignUpResource { createEmailLinkFlow = (): CreateEmailLinkFlowReturn => { const { run, stop } = Poller(); + let attempt = 0; const startEmailLinkFlow = async ({ redirectUrl }: StartEmailLinkFlowParams): Promise => { + const current = ++attempt; if (!this.id) { clerkVerifyEmailAddressCalledBeforeCreate('SignUp'); } @@ -265,6 +267,9 @@ export class SignUp extends BaseResource implements SignUpResource { }); return new Promise((resolve, reject) => { + if (current !== attempt) { + return; + } void run(() => { return this.reload() .then(res => { @@ -282,7 +287,12 @@ export class SignUp extends BaseResource implements SignUpResource { }); }; - return { startEmailLinkFlow, cancelEmailLinkFlow: stop }; + const cancelEmailLinkFlow = () => { + attempt++; + stop(); + }; + + return { startEmailLinkFlow, cancelEmailLinkFlow }; }; preparePhoneNumberVerification = (params?: PreparePhoneNumberVerificationParams): Promise => { 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 8043aba70d8..a0e00358454 100644 --- a/packages/clerk-js/src/core/resources/__tests__/EmailAddress.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/EmailAddress.test.ts @@ -120,4 +120,60 @@ describe('EmailAddress', () => { await Promise.all([first, second]); }); }); + describe.each([ + [ + 'createEmailLinkFlow', + (email: EmailAddress) => { + const { startEmailLinkFlow, cancelEmailLinkFlow } = email.createEmailLinkFlow(); + return { start: () => startEmailLinkFlow({ redirectUrl: '/verify' }), cancel: cancelEmailLinkFlow }; + }, + ], + [ + 'createEnterpriseSSOLinkFlow', + (email: EmailAddress) => { + const { startEnterpriseSSOLinkFlow, cancelEnterpriseSSOLinkFlow } = email.createEnterpriseSSOLinkFlow(); + return { + start: () => startEnterpriseSSOLinkFlow({ redirectUrl: '/verify' }), + cancel: cancelEnterpriseSSOLinkFlow, + }; + }, + ], + ])('%s', (_, createFlow) => { + const emailJSON = { + id: 'email_123', + verification: { status: 'unverified', external_verification_redirect_url: 'https://idp.example.com/sso' }, + }; + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('does not poll when cancelled while the verification is being prepared', async () => { + const prepared = createDeferredPromise(); + const mockFetch = vi.fn().mockReturnValueOnce(prepared.promise).mockResolvedValue({ response: emailJSON }); + BaseResource._fetch = mockFetch; + const flow = createFlow(new EmailAddress({ id: 'email_123' } as EmailAddressJSON, '/me/email_addresses')); + + void flow.start(); + flow.cancel(); + prepared.resolve({ response: emailJSON }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('polls once for a flow restarted while the cancelled start is still preparing', async () => { + const mockFetch = vi.fn().mockResolvedValue({ response: emailJSON }); + BaseResource._fetch = mockFetch; + const flow = createFlow(new EmailAddress({ id: 'email_123' } as EmailAddressJSON, '/me/email_addresses')); + + void flow.start(); + flow.cancel(); + void flow.start(); + await new Promise(resolve => setTimeout(resolve, 0)); + flow.cancel(); + + expect(mockFetch.mock.calls.map(([request]) => request.method)).toEqual(['POST', 'GET']); + }); + }); }); 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 53a50f0baa0..fedd0c62d51 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -4792,3 +4792,43 @@ describe('SignIn Slack workspace hint requests', () => { ); }); }); + +describe('SignIn createEmailLinkFlow', () => { + const signInJSON = { + client: null, + response: { id: 'signin_123', object: 'sign_in_attempt', first_factor_verification: { status: 'unverified' } }, + }; + + afterEach(() => { + BaseResource._fetch = resourceFetch; + vi.clearAllMocks(); + }); + + it('does not poll when cancelled while the link is being sent', async () => { + const prepared = createDeferredPromise(); + const mockFetch = vi.fn().mockReturnValueOnce(prepared.promise).mockResolvedValue(signInJSON); + BaseResource._fetch = mockFetch; + const { startEmailLinkFlow, cancelEmailLinkFlow } = new SignIn({ id: 'signin_123' } as any).createEmailLinkFlow(); + + void startEmailLinkFlow({ emailAddressId: 'idn_123', redirectUrl: '/verify' }); + cancelEmailLinkFlow(); + prepared.resolve(signInJSON); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('polls once for a flow restarted while the cancelled start is still sending the link', async () => { + const mockFetch = vi.fn().mockResolvedValue(signInJSON); + BaseResource._fetch = mockFetch; + const { startEmailLinkFlow, cancelEmailLinkFlow } = new SignIn({ id: 'signin_123' } as any).createEmailLinkFlow(); + + void startEmailLinkFlow({ emailAddressId: 'idn_123', redirectUrl: '/verify' }); + cancelEmailLinkFlow(); + void startEmailLinkFlow({ emailAddressId: 'idn_123', redirectUrl: '/verify' }); + await new Promise(resolve => setTimeout(resolve, 0)); + cancelEmailLinkFlow(); + + expect(mockFetch.mock.calls.map(([request]) => request.method)).toEqual(['POST', 'GET']); + }); +}); 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 67668057776..da7a7f76503 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -2665,3 +2665,47 @@ describe('SignUp Slack workspace hint requests', () => { expect(SignUp.clerk.__internal_environment?.reload).toHaveBeenCalledTimes(1); }); }); + +describe('SignUp createEmailLinkFlow', () => { + const signUpJSON = { + client: null, + response: { + id: 'signup_123', + object: 'sign_up_attempt', + verifications: { email_address: { status: 'unverified' } }, + }, + }; + + afterEach(() => { + BaseResource._fetch = resourceFetch; + vi.clearAllMocks(); + }); + + it('does not poll when cancelled while the link is being sent', async () => { + const prepared = createDeferredPromise(); + const mockFetch = vi.fn().mockReturnValueOnce(prepared.promise).mockResolvedValue(signUpJSON); + BaseResource._fetch = mockFetch; + const { startEmailLinkFlow, cancelEmailLinkFlow } = new SignUp({ id: 'signup_123' } as any).createEmailLinkFlow(); + + void startEmailLinkFlow({ redirectUrl: '/verify' }); + cancelEmailLinkFlow(); + prepared.resolve(signUpJSON); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('polls once for a flow restarted while the cancelled start is still sending the link', async () => { + const mockFetch = vi.fn().mockResolvedValue(signUpJSON); + BaseResource._fetch = mockFetch; + const { startEmailLinkFlow, cancelEmailLinkFlow } = new SignUp({ id: 'signup_123' } as any).createEmailLinkFlow(); + + void startEmailLinkFlow({ redirectUrl: '/verify' }); + cancelEmailLinkFlow(); + void startEmailLinkFlow({ redirectUrl: '/verify' }); + await new Promise(resolve => setTimeout(resolve, 0)); + cancelEmailLinkFlow(); + + expect(mockFetch.mock.calls.map(([request]) => request.method)).toEqual(['POST', 'GET']); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx index d28b064404a..4ad08fd01f1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx @@ -183,6 +183,23 @@ describe('the user profile email section', () => { expect(within(emailRow()).getByText('pending@example.com').parentElement).toHaveTextContent('Unverified'); }); + it('stops checking for the opened link once the link dialog is dismissed', async () => { + const pending = fapiEmailAddress({ id: 'idn_pending', email_address: 'pending@example.com' }); + const { actor } = await renderSection(signedInWithEmails([PRIMARY, pending], verifiesByLink)); + const poll = holdRequests('get', '/v1/me/email_addresses/:id'); + + await manageEmail(actor, 'pending@example.com', 'Verify'); + const dialog = await screen.findByRole('dialog', { name: 'Verify your email' }); + await waitFor(() => expect(poll.requests).toHaveLength(1)); + poll.release(); + await act(() => new Promise(resolve => setTimeout(resolve, 100))); + await actor.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + + await act(() => new Promise(resolve => setTimeout(resolve, 1500))); + expect(poll.requests).toHaveLength(1); + }); + it('sends the user to the identity provider for an address that matches an SSO connection, even when the instance verifies by link', async () => { const sso = fapiEmailAddress({ id: 'idn_sso', diff --git a/packages/shared/src/__tests__/poller.spec.ts b/packages/shared/src/__tests__/poller.spec.ts new file mode 100644 index 00000000000..cee7d3916b7 --- /dev/null +++ b/packages/shared/src/__tests__/poller.spec.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Poller } from '../poller'; + +vi.mock('../workerTimers', () => ({ + createWorkerTimers: () => { + let nextId = 0; + const timers = new Map>(); + return { + setTimeout: (cb: () => void, ms: number) => { + const id = nextId++; + timers.set(id, setTimeout(cb, ms)); + return id; + }, + clearTimeout: (id: number) => clearTimeout(timers.get(id)), + cleanup: () => {}, + }; + }, +})); + +describe('Poller', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('stops polling when stopped while waiting for the next tick', async () => { + const { run, stop } = Poller({ delayInMs: 100 }); + const cb = vi.fn(); + + await run(cb); + stop(); + await vi.advanceTimersByTimeAsync(500); + + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('stops polling when stopped from a later tick', async () => { + const { run, stop } = Poller({ delayInMs: 100 }); + const cb = vi.fn(); + + await run(cb); + await vi.advanceTimersByTimeAsync(100); + stop(); + await vi.advanceTimersByTimeAsync(500); + + expect(cb).toHaveBeenCalledTimes(2); + }); + + it('polls again when run after being stopped', async () => { + const { run, stop } = Poller({ delayInMs: 100 }); + const cb = vi.fn(); + + await run(cb); + stop(); + await run(cb); + await vi.advanceTimersByTimeAsync(100); + stop(); + + expect(cb).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/shared/src/poller.ts b/packages/shared/src/poller.ts index b0e3a3abe71..5d1fdb2c771 100644 --- a/packages/shared/src/poller.ts +++ b/packages/shared/src/poller.ts @@ -23,22 +23,27 @@ export function Poller({ delayInMs }: PollerOptions = { delayInMs: 1000 }): Poll let stopped = false; const stop: PollerStop = () => { - if (timerId) { + if (timerId !== undefined) { workerTimers.clearTimeout(timerId); workerTimers.cleanup(); + timerId = undefined; } stopped = true; }; const run: PollerRun = async cb => { stopped = false; + await poll(cb); + }; + + const poll = async (cb: PollerCallback) => { await cb(stop); if (stopped) { return; } timerId = workerTimers.setTimeout(() => { - void run(cb); + void poll(cb); }, delayInMs) as any as number; };