Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/olive-donuts-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

Fix the sign-in start card briefly flashing over `<SignIn />` after a verification code is accepted, before the app renders its signed-in state.
2 changes: 2 additions & 0 deletions .changeset/thin-dodos-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
9 changes: 9 additions & 0 deletions packages/ui/src/components/SignIn/SignInFactorOne.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,17 @@ function SignInFactorOneInternal(): JSX.Element {

const [passwordErrorCode, setPasswordErrorCode] = React.useState<PasswordErrorCode | null>(null);

const setActiveTookOverRef = React.useRef(false);

React.useEffect(() => {
if (__internal_setActiveInProgress) {
// setActive owns navigation from here on. It consumes the sign-in (status -> null), so the
// check below would fire as setActive winds down and flash the start card over a success.
setActiveTookOverRef.current = true;
return;
}

if (setActiveTookOverRef.current) {
return;
}

Expand Down
9 changes: 9 additions & 0 deletions packages/ui/src/components/SignIn/SignInFactorTwo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,17 @@ function SignInFactorTwoInternal(): JSX.Element {
const onShowAlternativeMethodsClicked =
signIn.supportedSecondFactors && signIn.supportedSecondFactors.length > 1 ? toggleAllStrategies : undefined;

const setActiveTookOverRef = React.useRef(false);

React.useEffect(() => {
if (clerk.__internal_setActiveInProgress) {
// setActive owns navigation from here on. It consumes the sign-in (status -> null), so the
// check below would fire as setActive winds down and redirect over a flow that succeeded.
setActiveTookOverRef.current = true;
return;
}

if (setActiveTookOverRef.current) {
return;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { ClerkAPIResponseError } from '@clerk/shared/error';
import type { SignInResource } from '@clerk/shared/types';
import { waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import { bindCreateFixtures } from '@/test/create-fixtures';
import { render, screen } from '@/test/utils';

import { SignInFactorOne } from '../SignInFactorOne';
import { SignInFactorTwo } from '../SignInFactorTwo';

const { createFixtures } = bindCreateFixtures('SignIn');

/**
* Mirrors the real `setActive` lifecycle: the flag goes up, the completed sign-in is consumed on
* the client (`status` -> `null`), the card re-renders while the flag is still up (clerk-js emits
* transitive state right before navigating), then the flag drops once navigation is done.
*/
const mockSetActiveLifecycle = (fixtures: any) => {
let release = () => {};
const gate = new Promise<void>(resolve => (release = resolve));

fixtures.clerk.setActive.mockImplementation(async (params: any) => {
fixtures.clerk.__internal_setActiveInProgress = true;
fixtures.signIn.status = null;
await gate;
await params.navigate?.({ session: { currentTask: null }, decorateUrl: (url: string) => url });
fixtures.clerk.__internal_setActiveInProgress = false;
});

return { finishSetActive: () => release() };
};

describe('SignIn setActive guard', () => {
it('does not bounce factor one back to the start card once setActive has completed', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPreferredSignInStrategy({ strategy: 'otp' });
f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false });
});

fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
fixtures.signIn.attemptFirstFactor.mockResolvedValueOnce({
status: 'complete',
createdSessionId: 'sess_123',
} as any);
const { finishSetActive } = mockSetActiveLifecycle(fixtures);

const { userEvent, rerender } = render(<SignInFactorOne />, { wrapper });

await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456');
await waitFor(() => expect(fixtures.clerk.setActive).toHaveBeenCalled(), { timeout: 3000 });

rerender(<SignInFactorOne />);
finishSetActive();
await waitFor(() => expect((fixtures.clerk as any).__internal_setActiveInProgress).toBe(false));

// The host app keeps <SignIn> mounted until its own signed-in state propagates, so the card
// re-renders at least once more after setActive resolves.
rerender(<SignInFactorOne />);

await waitFor(() => expect(fixtures.clerk.setActive).toHaveBeenCalled());
expect(fixtures.router.navigate).not.toHaveBeenCalledWith('../');
});

it('does not bounce back to the start card after a signUpIfMissing transfer completes', async () => {
const { wrapper, fixtures, props } = await createFixtures(f => {
f.withEmailAddress();
f.withPreferredSignInStrategy({ strategy: 'otp' });
f.withEnumerationProtection();
f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false });
});
props.setProps({ withSignUp: true });

fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
fixtures.signIn.attemptFirstFactor.mockImplementationOnce(() => {
(fixtures.signIn as any).firstFactorVerification = { status: 'transferable' };
return Promise.reject(
new ClerkAPIResponseError('Error', {
data: [{ code: 'sign_up_if_missing_transfer', long_message: '', message: '' }],
status: 404,
}),
);
});
// A sign-up with no additional requirements transfers straight to `complete`.
fixtures.signUp.create.mockResolvedValueOnce({ status: 'complete', createdSessionId: 'sess_123' } as any);
const { finishSetActive } = mockSetActiveLifecycle(fixtures);

const { userEvent, rerender } = render(<SignInFactorOne />, { wrapper });

await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456');
await waitFor(() => expect(fixtures.clerk.setActive).toHaveBeenCalled(), { timeout: 3000 });

rerender(<SignInFactorOne />);
finishSetActive();
await waitFor(() => expect((fixtures.clerk as any).__internal_setActiveInProgress).toBe(false));

// The terminal redirect leaves the page, but the document stays alive while the browser
// fetches the next one, so the card can still re-render and bounce.
rerender(<SignInFactorOne />);

expect(fixtures.router.navigate).not.toHaveBeenCalledWith('../');
});

it('still bounces to the start card when the sign-in was abandoned without setActive', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPreferredSignInStrategy({ strategy: 'otp' });
f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false });
});

fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
(fixtures.signIn as any).status = 'needs_identifier';

render(<SignInFactorOne />, { wrapper });

await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('../'));
});

it('still bounces if another session is activated before the sign-in is abandoned', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withMultiSessionMode();
f.withEmailAddress();
f.withPreferredSignInStrategy({ strategy: 'otp' });
f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false });
});

fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
const { rerender } = render(<SignInFactorOne />, { wrapper });

fixtures.clerk.__internal_setActiveInProgress = true;
rerender(<SignInFactorOne />);

fixtures.clerk.__internal_setActiveInProgress = false;
(fixtures.signIn as any).status = 'needs_identifier';
rerender(<SignInFactorOne />);

await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('../'), { timeout: 1000 });

Check failure on line 138 in packages/ui/src/components/SignIn/__tests__/SignInFactorOneSetActiveGuard.test.tsx

View workflow job for this annotation

GitHub Actions / Unit Tests (**)

src/components/SignIn/__tests__/SignInFactorOneSetActiveGuard.test.tsx > SignIn setActive guard > still bounces if another session is activated before the sign-in is abandoned

AssertionError: expected "vi.fn()" to be called with arguments: [ '../' ] Number of calls: 0 Ignored nodes: comments, script, style <html> <head /> <body> <div> <div class="cl-cardBox cl-SignIn-emailCode 🔒️ css-lesbzj" > <div class="cl-card cl-SignIn-emailCode 🔒️ css-umn37z" > <div class="cl-header 🔒️ css-1f4p2mz" > <div class="css-x0fvpz" > <h1 class="cl-headerTitle 🔒️ css-nloh3e-Heading" data-localization-key="signIn.emailCode.title" > Check your email </h1> <p class="cl-headerSubtitle 🔒️ css-1lo02zq" data-color="secondary" data-localization-key="signIn.emailCode.subtitle" data-variant="body" > to continue to TestApp </p> <div class="cl-identityPreview 🔒️ css-1389xgv" > <p class="cl-identityPreviewText 🔒️ css-q0hi2t" data-color="secondary" data-variant="body" > hello@clerk.com </p> <button aria-label="Edit email address" class="cl-identityPreviewEditButton cl-button 🔒️ css-1i7faf8" data-color="primary" data-variant="link" > <span class="cl-identityPreviewEditButtonIcon 🔒️ css-1ctxcx1-Icon" /> </button> </div> </div> </div> <div class="cl-main 🔒️ css-ji79b9" > <div class="cl-form 🔒️ css-2l8bgd" > <div class="cl-otpCodeField 🔒️ css-1ugvctd" > <div class="cl-otpCodeFieldInputContainer 🔒️ css-1quu1j7" > <noscript /> <div data-input-otp-container="true" style="position: relative; cursor: text; user-select: none; pointer-events: none; --root-height: 0px;" > <div class="cl-otpCodeFieldInputs 🔒️ css-1erfakr" role="group" > <div aria-invalid="false" class="cl-otpCodeFieldInput cl-input 🔒️ css-1n91pqk" data-feedback="info" data-focus-within="true" data-testid="otp-input-segment" data-variant="default" > <div class="css-1qlrn70" > <div class="css-31k80" /> </div> </div> <div aria-invalid="false" class="cl-otpCodeFieldInput cl-input 🔒️ css-yloboq" data-feedback="info" data-focus-within="false" data-testid="otp-input-segment" data-variant="default" /> <div aria-invalid="false" class="cl-otpCodeFieldInput cl-input 🔒️ css-yloboq" data-feedback="info" data-focus-within="false" data-testid="otp-input-segment" data-variant="default" /> <div aria-invalid="false" class="cl-otpCodeFieldInput cl-input �

Check failure on line 138 in packages/ui/src/components/SignIn/__tests__/SignInFactorOneSetActiveGuard.test.tsx

View workflow job for this annotation

GitHub Actions / Unit Tests (**)

src/components/SignIn/__tests__/SignInFactorOneSetActiveGuard.test.tsx > SignIn setActive guard > still bounces if another session is activated before the sign-in is abandoned

AssertionError: expected "vi.fn()" to be called with arguments: [ '../' ] Number of calls: 0 Ignored nodes: comments, script, style <html> <head /> <body> <div> <div class="cl-cardBox cl-SignIn-emailCode 🔒️ css-lesbzj" > <div class="cl-card cl-SignIn-emailCode 🔒️ css-umn37z" > <div class="cl-header 🔒️ css-1f4p2mz" > <div class="css-x0fvpz" > <h1 class="cl-headerTitle 🔒️ css-nloh3e-Heading" data-localization-key="signIn.emailCode.title" > Check your email </h1> <p class="cl-headerSubtitle 🔒️ css-1lo02zq" data-color="secondary" data-localization-key="signIn.emailCode.subtitle" data-variant="body" > to continue to TestApp </p> <div class="cl-identityPreview 🔒️ css-1389xgv" > <p class="cl-identityPreviewText 🔒️ css-q0hi2t" data-color="secondary" data-variant="body" > hello@clerk.com </p> <button aria-label="Edit email address" class="cl-identityPreviewEditButton cl-button 🔒️ css-1i7faf8" data-color="primary" data-variant="link" > <span class="cl-identityPreviewEditButtonIcon 🔒️ css-1ctxcx1-Icon" /> </button> </div> </div> </div> <div class="cl-main 🔒️ css-ji79b9" > <div class="cl-form 🔒️ css-2l8bgd" > <div class="cl-otpCodeField 🔒️ css-1ugvctd" > <div class="cl-otpCodeFieldInputContainer 🔒️ css-1quu1j7" > <noscript /> <div data-input-otp-container="true" style="position: relative; cursor: text; user-select: none; pointer-events: none; --root-height: 0px;" > <div class="cl-otpCodeFieldInputs 🔒️ css-1erfakr" role="group" > <div aria-invalid="false" class="cl-otpCodeFieldInput cl-input 🔒️ css-1n91pqk" data-feedback="info" data-focus-within="true" data-testid="otp-input-segment" data-variant="default" > <div class="css-1qlrn70" > <div class="css-31k80" /> </div> </div> <div aria-invalid="false" class="cl-otpCodeFieldInput cl-input 🔒️ css-yloboq" data-feedback="info" data-focus-within="false" data-testid="otp-input-segment" data-variant="default" /> <div aria-invalid="false" class="cl-otpCodeFieldInput cl-input 🔒️ css-yloboq" data-feedback="info" data-focus-within="false" data-testid="otp-input-segment" data-variant="default" /> <div aria-invalid="false" class="cl-otpCodeFieldInput cl-input �
});

it('still bounces factor two if another session is activated before the sign-in returns to factor one', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withMultiSessionMode();
f.startSignInFactorTwo();
});

fixtures.signIn.prepareSecondFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
const { rerender } = render(<SignInFactorTwo />, { wrapper });

fixtures.clerk.__internal_setActiveInProgress = true;
rerender(<SignInFactorTwo />);

fixtures.clerk.__internal_setActiveInProgress = false;
(fixtures.signIn as any).status = 'needs_first_factor';
rerender(<SignInFactorTwo />);

await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('../'), { timeout: 1000 });

Check failure on line 157 in packages/ui/src/components/SignIn/__tests__/SignInFactorOneSetActiveGuard.test.tsx

View workflow job for this annotation

GitHub Actions / Unit Tests (**)

src/components/SignIn/__tests__/SignInFactorOneSetActiveGuard.test.tsx > SignIn setActive guard > still bounces factor two if another session is activated before the sign-in returns to factor one

AssertionError: expected "vi.fn()" to be called with arguments: [ '../' ] Number of calls: 0 Ignored nodes: comments, script, style <html> <head /> <body> <div> <div class="cl-cardBox cl-SignIn-phoneCode2Fa 🔒️ css-lesbzj" > <div class="cl-card cl-SignIn-phoneCode2Fa 🔒️ css-umn37z" > <div class="cl-header 🔒️ css-1f4p2mz" > <div class="css-x0fvpz" > <h1 class="cl-headerTitle 🔒️ css-nloh3e-Heading" data-localization-key="signIn.phoneCodeMfa.title" > Check your phone </h1> <p class="cl-headerSubtitle 🔒️ css-1lo02zq" data-color="secondary" data-localization-key="signIn.phoneCodeMfa.subtitle" data-variant="body" > To continue, please enter the verification code sent to your phone </p> <div class="cl-identityPreview 🔒️ css-1389xgv" > <p class="css-8vjrra" data-color="inherit" data-variant="body" > 🇬🇷 </p> <p class="cl-identityPreviewText 🔒️ css-q0hi2t" data-color="secondary" data-variant="body" > +30 691 1111111 </p> </div> </div> </div> <div class="cl-main 🔒️ css-ji79b9" > <div class="cl-form 🔒️ css-2l8bgd" > <div class="cl-otpCodeField 🔒️ css-1ugvctd" > <div class="cl-otpCodeFieldInputContainer 🔒️ css-1quu1j7" > <noscript /> <div data-input-otp-container="true" style="position: relative; cursor: text; user-select: none; pointer-events: none; --root-height: 0px;" > <div class="cl-otpCodeFieldInputs 🔒️ css-1erfakr" role="group" > <div aria-invalid="false" class="cl-otpCodeFieldInput cl-input 🔒️ css-1n91pqk" data-feedback="info" data-focus-within="true" data-testid="otp-input-segment" data-variant="default" > <div class="css-1qlrn70" > <div class="css-31k80" /> </div> </div> <div aria-invalid="false" class="cl-otpCodeFieldInput cl-input 🔒️ css-yloboq" data-feedback="info" data-focus-within="false" data-testid="otp-input-segment" data-variant="default" /> <div aria-invalid="false" class="cl-otpCodeFieldInput cl-input 🔒️ css-yloboq" data-feedback="info" data-focus-within="false" data-testid="otp-input-segment" data-variant="default" /> <div aria-invalid="false" class="cl-otpCodeFieldInput cl-input 🔒️ css-yloboq" data-feedback="info" data-focus-within="false" data-testid="otp-i

Check failure on line 157 in packages/ui/src/components/SignIn/__tests__/SignInFactorOneSetActiveGuard.test.tsx

View workflow job for this annotation

GitHub Actions / Unit Tests (**)

src/components/SignIn/__tests__/SignInFactorOneSetActiveGuard.test.tsx > SignIn setActive guard > still bounces factor two if another session is activated before the sign-in returns to factor one

AssertionError: expected "vi.fn()" to be called with arguments: [ '../' ] Number of calls: 0 Ignored nodes: comments, script, style <html> <head /> <body> <div> <div class="cl-cardBox cl-SignIn-phoneCode2Fa 🔒️ css-lesbzj" > <div class="cl-card cl-SignIn-phoneCode2Fa 🔒️ css-umn37z" > <div class="cl-header 🔒️ css-1f4p2mz" > <div class="css-x0fvpz" > <h1 class="cl-headerTitle 🔒️ css-nloh3e-Heading" data-localization-key="signIn.phoneCodeMfa.title" > Check your phone </h1> <p class="cl-headerSubtitle 🔒️ css-1lo02zq" data-color="secondary" data-localization-key="signIn.phoneCodeMfa.subtitle" data-variant="body" > To continue, please enter the verification code sent to your phone </p> <div class="cl-identityPreview 🔒️ css-1389xgv" > <p class="css-8vjrra" data-color="inherit" data-variant="body" > 🇬🇷 </p> <p class="cl-identityPreviewText 🔒️ css-q0hi2t" data-color="secondary" data-variant="body" > +30 691 1111111 </p> </div> </div> </div> <div class="cl-main 🔒️ css-ji79b9" > <div class="cl-form 🔒️ css-2l8bgd" > <div class="cl-otpCodeField 🔒️ css-1ugvctd" > <div class="cl-otpCodeFieldInputContainer 🔒️ css-1quu1j7" > <noscript /> <div data-input-otp-container="true" style="position: relative; cursor: text; user-select: none; pointer-events: none; --root-height: 0px;" > <div class="cl-otpCodeFieldInputs 🔒️ css-1erfakr" role="group" > <div aria-invalid="false" class="cl-otpCodeFieldInput cl-input 🔒️ css-1n91pqk" data-feedback="info" data-focus-within="true" data-testid="otp-input-segment" data-variant="default" > <div class="css-1qlrn70" > <div class="css-31k80" /> </div> </div> <div aria-invalid="false" class="cl-otpCodeFieldInput cl-input 🔒️ css-yloboq" data-feedback="info" data-focus-within="false" data-testid="otp-input-segment" data-variant="default" /> <div aria-invalid="false" class="cl-otpCodeFieldInput cl-input 🔒️ css-yloboq" data-feedback="info" data-focus-within="false" data-testid="otp-input-segment" data-variant="default" /> <div aria-invalid="false" class="cl-otpCodeFieldInput cl-input 🔒️ css-yloboq" data-feedback="info" data-focus-within="false" data-testid="otp-i
});
});
Loading