Repository navigation
fix(clerk-js): stop email link polling after the flow is cancelled #10535
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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))); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Synchronize the first poll before testing cancellation. The fixed 100 ms wait does not prove that the released GET has completed. On a slow run, dismissal can stop the Poller before it schedules its next timer; the final assertion can then pass without testing timer cancellation. Wait for the first reload to complete, then use a controlled clock to check whether another poll starts. (raw.githubusercontent.com) As per coding guidelines, Mosaic feature tests must “Control time instead of waiting it out.” Also applies to: 199-199
|
||
| 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', | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<number, ReturnType<typeof setTimeout>>(); | ||
| 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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: clerk/javascript
Length of output: 8273
Script executed:
Repository: clerk/javascript
Length of output: 28013
Guard stale
reload()completions before stopping or settling the flow.If cancellation occurs while an attempt’s
reload()is pending, a restarted attempt can reuse the samePoller. The earlier completion can callstop()and stop the restarted poller, while the restarted promise remains pending. Checkcurrent !== attemptinside eachreload()completion and error handler before stopping or settling the flow. Apply the same change to the Enterprise SSO, SignIn, and SignUp flows.This race also existed before this PR, so it is not newly introduced. The current attempt guard does not prevent it.
Suggested fix
void run(() => { return this.reload() .then(res => { + if (current !== attempt) { + return; + } if (res.verification.status === 'verified') { stop(); resolve(res); } }) .catch(err => { + if (current !== attempt) { + return; + } stop(); reject(err); });