From 801a764d81e3c08b0fe5ab39628e6b6f5f27d0f1 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:02:11 -0600 Subject: [PATCH 1/8] feat(nextjs,react): Add HandleSSOCallback component --- .changeset/vast-loops-open.md | 6 + .../src/client-boundary/uiComponents.tsx | 1 + .../src/components/HandleSSOCallback.tsx | 177 ++++++++++++++++++ packages/react/src/components/index.ts | 1 + 4 files changed, 185 insertions(+) create mode 100644 .changeset/vast-loops-open.md create mode 100644 packages/react/src/components/HandleSSOCallback.tsx diff --git a/.changeset/vast-loops-open.md b/.changeset/vast-loops-open.md new file mode 100644 index 00000000000..9efa5da8526 --- /dev/null +++ b/.changeset/vast-loops-open.md @@ -0,0 +1,6 @@ +--- +'@clerk/nextjs': minor +'@clerk/react': minor +--- + +Add `HandleSSOCallback` component which handles the SSO callback during custom flows, including support for sign-in-or-up. diff --git a/packages/nextjs/src/client-boundary/uiComponents.tsx b/packages/nextjs/src/client-boundary/uiComponents.tsx index 6d032dc45c5..eef6fe05152 100644 --- a/packages/nextjs/src/client-boundary/uiComponents.tsx +++ b/packages/nextjs/src/client-boundary/uiComponents.tsx @@ -27,6 +27,7 @@ export { UserAvatar, UserButton, Waitlist, + HandleSSOCallback, } from '@clerk/react'; // The assignment of UserProfile with BaseUserProfile props is used diff --git a/packages/react/src/components/HandleSSOCallback.tsx b/packages/react/src/components/HandleSSOCallback.tsx new file mode 100644 index 00000000000..8109017a5be --- /dev/null +++ b/packages/react/src/components/HandleSSOCallback.tsx @@ -0,0 +1,177 @@ +import type { SetActiveNavigate } from '@clerk/shared/types'; +import { useEffect, useRef, type ReactNode } from 'react'; +import { useClerk, useSignIn, useSignUp } from '../hooks'; + +export interface HandleSSOCallbackProps { + /** + * Called when the SSO callback is complete and a session has been created. + */ + navigateToApp: (...params: Parameters) => void; + /** + * Called when a sign-in requires additional verification, or a sign-up is transfered to a sign-in that requires + * additional verification. + */ + navigateToSignIn: () => void; + /** + * Called when a sign-in is transfered to a sign-up that requires additional verification. + */ + navigateToSignUp: () => void; + /** + * Can be provided to render a custom component while the SSO callback is being processed. This component should, at + * a minimum, render a `
` element to handle captchas. + */ + render?: () => ReactNode; +} + +/** + * Use this component when building custom UI to handle the SSO callback and navigate to the appropriate page based on + * the status of the sign-in or sign-up. By default, this component might render a captcha element to handle captchas + * when required by the Clerk API. + * + * @example + * ```tsx + * import { HandleSSOCallback } from '@clerk/react'; + * import { useNavigate } from 'react-router'; + * + * export default function Page() { + * const navigate = useNavigate(); + * + * return ( + * { + * if (session?.currentTask) { + * const destination = decorateUrl(`/onboarding/${session?.currentTask.key}`); + * if (destination.startsWith('http')) { + * window.location.href = destination; + * return; + * } + * navigate(destination); + * return; + * } + * + * const destination = decorateUrl('/dashboard'); + * if (destination.startsWith('http')) { + * window.location.href = destination; + * return; + * } + * navigate(destination); + * }} + * navigateToSignIn={() => { + * navigate('/sign-in'); + * }} + * navigateToSignUp={() => { + * navigate('/sign-up'); + * }} + * /> + * ); + * } + * ``` + */ +export function HandleSSOCallback(props: HandleSSOCallbackProps): ReactNode { + const { navigateToApp, navigateToSignIn, navigateToSignUp, render } = props; + const clerk = useClerk(); + const { signIn } = useSignIn(); + const { signUp } = useSignUp(); + const hasRun = useRef(false); + + useEffect(() => { + (async () => { + if (!clerk.loaded || hasRun.current) { + return; + } + // Prevent re-running this effect if the page is re-rendered during session activation (such as on Next.js). + hasRun.current = true; + + // If this was a sign-in, and it's complete, there's nothing else to do. + // Note: We perform a cast + if ((signIn.status as string) === 'complete') { + await signIn.finalize({ + navigate: async (...params) => { + navigateToApp(...params); + }, + }); + return; + } + + // If the sign-up used an existing account, transfer it to a sign-in. + if (signUp.isTransferable) { + await signIn.create({ transfer: true }); + if (signIn.status === 'complete') { + await signIn.finalize({ + navigate: async (...params) => { + navigateToApp(...params); + }, + }); + return; + } + // The sign-in requires additional verification, so we need to navigate to the sign-in page. + return navigateToSignIn(); + } + + if ( + signIn.status === 'needs_first_factor' && + !signIn.supportedFirstFactors?.every(f => f.strategy === 'enterprise_sso') + ) { + // The sign-in requires the use of a configured first factor, so navigate to the sign-in page. + return navigateToSignIn(); + } + + // If the sign-in used an external account not associated with an existing user, create a sign-up. + if (signIn.isTransferable) { + await signUp.create({ transfer: true }); + if (signUp.status === 'complete') { + await signUp.finalize({ + navigate: async (...params) => { + navigateToApp(...params); + }, + }); + return; + } + return navigateToSignUp(); + } + + if (signUp.status === 'complete') { + await signUp.finalize({ + navigate: async (...params) => { + navigateToApp(...params); + }, + }); + return; + } + + if (signIn.status === 'needs_second_factor' || signIn.status === 'needs_new_password') { + // The sign-in requires a MFA token or a new password, so navigate to the sign-in page. + return navigateToSignIn(); + } + + // The external account used to sign-in or sign-up was already associated with an existing user and active + // session on this client, so activate the session and navigate to the application. + if (signIn.existingSession || signUp.existingSession) { + const sessionId = signIn.existingSession?.sessionId || signUp.existingSession?.sessionId; + if (sessionId) { + // Because we're activating a session that's not the result of a sign-in or sign-up, we need to use the + // Clerk `setActive` API instead of the `finalize` API. + await clerk.setActive({ + session: sessionId, + navigate: async (...params) => { + return navigateToApp(...params); + }, + }); + return; + } + } + })(); + }, [clerk, signIn, signUp]); + + if (render) { + return render(); + } + + return ( +
+ {/* Because a sign-in transferred to a sign-up might require captcha verification, make sure to render the + captcha element. */} +
+
+ ); +} diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index c200f386236..8daec8bb784 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -37,3 +37,4 @@ export { SignInButton } from './SignInButton'; export { SignInWithMetamaskButton } from './SignInWithMetamaskButton'; export { SignOutButton } from './SignOutButton'; export { SignUpButton } from './SignUpButton'; +export { HandleSSOCallback } from './HandleSSOCallback'; From 9cf0701935cc4fad8b99743196a695e7b2b83d70 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:23:40 -0600 Subject: [PATCH 2/8] fix(react): Import React --- packages/react/src/components/HandleSSOCallback.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/components/HandleSSOCallback.tsx b/packages/react/src/components/HandleSSOCallback.tsx index 8109017a5be..2d70af48f5a 100644 --- a/packages/react/src/components/HandleSSOCallback.tsx +++ b/packages/react/src/components/HandleSSOCallback.tsx @@ -1,5 +1,5 @@ import type { SetActiveNavigate } from '@clerk/shared/types'; -import { useEffect, useRef, type ReactNode } from 'react'; +import React, { useEffect, useRef, type ReactNode } from 'react'; import { useClerk, useSignIn, useSignUp } from '../hooks'; export interface HandleSSOCallbackProps { From 23104dce56f92357070d4e6c1b77b50fb80ad82c Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:39:09 -0600 Subject: [PATCH 3/8] fix(react): sort imports --- packages/react/src/components/HandleSSOCallback.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/HandleSSOCallback.tsx b/packages/react/src/components/HandleSSOCallback.tsx index 2d70af48f5a..538c0e339ad 100644 --- a/packages/react/src/components/HandleSSOCallback.tsx +++ b/packages/react/src/components/HandleSSOCallback.tsx @@ -1,5 +1,6 @@ import type { SetActiveNavigate } from '@clerk/shared/types'; -import React, { useEffect, useRef, type ReactNode } from 'react'; +import React, { type ReactNode, useEffect, useRef } from 'react'; + import { useClerk, useSignIn, useSignUp } from '../hooks'; export interface HandleSSOCallbackProps { @@ -171,7 +172,7 @@ export function HandleSSOCallback(props: HandleSSOCallbackProps): ReactNode {
{/* Because a sign-in transferred to a sign-up might require captcha verification, make sure to render the captcha element. */} -
+
); } From f04428f1996e75e14c5e6d770928d88dee0a7f14 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:40:48 -0600 Subject: [PATCH 4/8] fix(tanstack-react-start): update snapshot --- .../src/__tests__/__snapshots__/exports.test.ts.snap | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index 6e0d04985b8..903c5107080 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -30,6 +30,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "ClerkProvider", "CreateOrganization", "GoogleOneTap", + "HandleSSOCallback", "OrganizationList", "OrganizationProfile", "OrganizationSwitcher", From f0566318b3ebb270d128a1235e79ed4d0707df39 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:46:59 -0600 Subject: [PATCH 5/8] tests(chrome-extension,react-router): update snapshots --- .../src/__tests__/__snapshots__/exports.test.ts.snap | 1 + .../src/__tests__/__snapshots__/exports.test.ts.snap | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap index a3dc11760d2..dcf9f52e07a 100644 --- a/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap @@ -11,6 +11,7 @@ exports[`public exports > should not include a breaking change 1`] = ` "ClerkProvider", "CreateOrganization", "GoogleOneTap", + "HandleSSOCallback", "OrganizationList", "OrganizationProfile", "OrganizationSwitcher", diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index 87dc4845653..aaf05912db6 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -25,6 +25,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "ClerkProvider", "CreateOrganization", "GoogleOneTap", + "HandleSSOCallback", "OrganizationList", "OrganizationProfile", "OrganizationSwitcher", From 146a6c8147a97792dcb4ffc94b6a8331ddc534da Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:57:18 -0600 Subject: [PATCH 6/8] fix(chrome-extension): Re-export HandleSSOCallback --- .changeset/vast-loops-open.md | 1 + packages/chrome-extension/src/react/re-exports.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/.changeset/vast-loops-open.md b/.changeset/vast-loops-open.md index 9efa5da8526..baf4dc4e371 100644 --- a/.changeset/vast-loops-open.md +++ b/.changeset/vast-loops-open.md @@ -1,4 +1,5 @@ --- +'@clerk/chrome-extension': minor '@clerk/nextjs': minor '@clerk/react': minor --- diff --git a/packages/chrome-extension/src/react/re-exports.ts b/packages/chrome-extension/src/react/re-exports.ts index 0c4b6c83fdf..2ef5056cb2e 100644 --- a/packages/chrome-extension/src/react/re-exports.ts +++ b/packages/chrome-extension/src/react/re-exports.ts @@ -6,6 +6,7 @@ export { ClerkLoaded, ClerkLoading, CreateOrganization, + HandleSSOCallback, OrganizationList, OrganizationProfile, OrganizationSwitcher, From 433b4fe9e9d80a2258ddc23f1fa0b0ba9f7ef27b Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:30:31 -0600 Subject: [PATCH 7/8] docs(clerk-js): Fix missing comment --- packages/react/src/components/HandleSSOCallback.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react/src/components/HandleSSOCallback.tsx b/packages/react/src/components/HandleSSOCallback.tsx index 538c0e339ad..88fd7348cd9 100644 --- a/packages/react/src/components/HandleSSOCallback.tsx +++ b/packages/react/src/components/HandleSSOCallback.tsx @@ -84,7 +84,8 @@ export function HandleSSOCallback(props: HandleSSOCallbackProps): ReactNode { hasRun.current = true; // If this was a sign-in, and it's complete, there's nothing else to do. - // Note: We perform a cast + // Note: We perform a cast here to prevent TypeScript from narrowing the type of signIn.status. TypeScript + // doesn't understand that the status can be mutated during the execution of this function. if ((signIn.status as string) === 'complete') { await signIn.finalize({ navigate: async (...params) => { From b06d2e572c5f88b6bee6a4afb7ac6abacb93979c Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:17:55 -0600 Subject: [PATCH 8/8] tests(react): Add unit tests for HandleSSOCallback component --- .../__tests__/HandleSSOCallback.test.tsx | 393 ++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 packages/react/src/components/__tests__/HandleSSOCallback.test.tsx diff --git a/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx b/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx new file mode 100644 index 00000000000..1f800b257f3 --- /dev/null +++ b/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx @@ -0,0 +1,393 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { HandleSSOCallback } from '../HandleSSOCallback'; + +const mockNavigateToApp = vi.fn(); +const mockNavigateToSignIn = vi.fn(); +const mockNavigateToSignUp = vi.fn(); + +const mockSignInFinalize = vi.fn().mockImplementation(async ({ navigate }) => { + await navigate({ session: { id: 'sess_sign_in' }, decorateUrl: (url: string) => url }); + return { error: null }; +}); +const mockSignInCreate = vi.fn().mockResolvedValue({ error: null }); +const mockSignUpFinalize = vi.fn().mockImplementation(async ({ navigate }) => { + await navigate({ session: { id: 'sess_sign_up' }, decorateUrl: (url: string) => url }); + return { error: null }; +}); +const mockSignUpCreate = vi.fn().mockResolvedValue({ error: null }); +const mockSetActive = vi.fn().mockImplementation(async ({ navigate }) => { + await navigate({ session: { id: 'sess_existing' }, decorateUrl: (url: string) => url }); +}); + +let mockClerkLoaded = true; +let mockSignIn: Record = {}; +let mockSignUp: Record = {}; + +vi.mock('../../../src/hooks', () => ({ + useClerk: () => ({ + loaded: mockClerkLoaded, + setActive: mockSetActive, + }), + useSignIn: () => ({ + signIn: { + finalize: mockSignInFinalize, + create: mockSignInCreate, + get status() { + return mockSignIn.status; + }, + get isTransferable() { + return mockSignIn.isTransferable; + }, + get supportedFirstFactors() { + return mockSignIn.supportedFirstFactors; + }, + get existingSession() { + return mockSignIn.existingSession; + }, + }, + }), + useSignUp: () => ({ + signUp: { + finalize: mockSignUpFinalize, + create: mockSignUpCreate, + get status() { + return mockSignUp.status; + }, + get isTransferable() { + return mockSignUp.isTransferable; + }, + get existingSession() { + return mockSignUp.existingSession; + }, + }, + }), +})); + +describe('', () => { + let consoleErrorSpy: ReturnType; + + beforeAll(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterAll(() => { + consoleErrorSpy.mockRestore(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockClerkLoaded = true; + mockSignIn = {}; + mockSignUp = {}; + }); + + it('renders captcha element by default', () => { + mockClerkLoaded = false; + render( + , + ); + + expect(document.getElementById('clerk-captcha')).not.toBeNull(); + }); + + it('renders custom component when render prop is provided', async () => { + mockClerkLoaded = false; + render( +
Loading...
} + />, + ); + + await screen.findByTestId('custom-render'); + await screen.findByText('Loading...'); + }); + + it('does nothing when clerk is not loaded', async () => { + mockClerkLoaded = false; + render( + , + ); + + await waitFor(() => { + expect(mockSignInFinalize).not.toHaveBeenCalled(); + expect(mockSignUpFinalize).not.toHaveBeenCalled(); + expect(mockNavigateToApp).not.toHaveBeenCalled(); + expect(mockNavigateToSignIn).not.toHaveBeenCalled(); + expect(mockNavigateToSignUp).not.toHaveBeenCalled(); + }); + }); + + it('finalizes sign-in and navigates to app when signIn.status is complete', async () => { + mockSignIn = { status: 'complete' }; + + render( + , + ); + + await waitFor(() => { + expect(mockSignInFinalize).toHaveBeenCalled(); + expect(mockNavigateToApp).toHaveBeenCalled(); + }); + }); + + it('transfers sign-up to sign-in when signUp.isTransferable is true and sign-in completes', async () => { + mockSignUp = { isTransferable: true }; + mockSignIn = { status: 'needs_identifier' }; + + mockSignInCreate.mockImplementation(async () => { + mockSignIn.status = 'complete'; + return { error: null }; + }); + + render( + , + ); + + await waitFor(() => { + expect(mockSignInCreate).toHaveBeenCalledWith({ transfer: true }); + expect(mockSignInFinalize).toHaveBeenCalled(); + expect(mockNavigateToApp).toHaveBeenCalled(); + }); + }); + + it('navigates to sign-in when signUp.isTransferable is true but sign-in needs verification', async () => { + mockSignUp = { isTransferable: true }; + mockSignIn = { status: 'needs_identifier' }; + + mockSignInCreate.mockImplementation(async () => { + mockSignIn.status = 'needs_first_factor'; + return { error: null }; + }); + + render( + , + ); + + await waitFor(() => { + expect(mockSignInCreate).toHaveBeenCalledWith({ transfer: true }); + expect(mockNavigateToSignIn).toHaveBeenCalled(); + }); + }); + + it('navigates to sign-in when signIn.status is needs_first_factor with non-enterprise SSO factors', async () => { + mockSignIn = { + status: 'needs_first_factor', + supportedFirstFactors: [{ strategy: 'password' }, { strategy: 'email_code' }], + }; + + render( + , + ); + + await waitFor(() => { + expect(mockNavigateToSignIn).toHaveBeenCalled(); + }); + }); + + it('transfers sign-in to sign-up when signIn.isTransferable is true and sign-up completes', async () => { + mockSignIn = { status: 'needs_identifier', isTransferable: true }; + mockSignUp = { status: 'missing_requirements' }; + + mockSignUpCreate.mockImplementation(async () => { + mockSignUp.status = 'complete'; + return { error: null }; + }); + + render( + , + ); + + await waitFor(() => { + expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true }); + expect(mockSignUpFinalize).toHaveBeenCalled(); + expect(mockNavigateToApp).toHaveBeenCalled(); + }); + }); + + it('navigates to sign-up when signIn.isTransferable is true but sign-up needs verification', async () => { + mockSignIn = { status: 'needs_identifier', isTransferable: true }; + mockSignUp = { status: 'missing_requirements' }; + + mockSignUpCreate.mockImplementation(async () => { + mockSignUp.status = 'missing_requirements'; + return { error: null }; + }); + + render( + , + ); + + await waitFor(() => { + expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true }); + expect(mockNavigateToSignUp).toHaveBeenCalled(); + }); + }); + + it('finalizes sign-up and navigates to app when signUp.status is complete', async () => { + mockSignIn = { status: 'needs_identifier', isTransferable: false }; + mockSignUp = { status: 'complete', isTransferable: false }; + + render( + , + ); + + await waitFor(() => { + expect(mockSignUpFinalize).toHaveBeenCalled(); + expect(mockNavigateToApp).toHaveBeenCalled(); + }); + }); + + it('navigates to sign-in when signIn.status is needs_second_factor', async () => { + mockSignIn = { status: 'needs_second_factor', isTransferable: false }; + mockSignUp = { status: 'missing_requirements', isTransferable: false }; + + render( + , + ); + + await waitFor(() => { + expect(mockNavigateToSignIn).toHaveBeenCalled(); + }); + }); + + it('navigates to sign-in when signIn.status is needs_new_password', async () => { + mockSignIn = { status: 'needs_new_password', isTransferable: false }; + mockSignUp = { status: 'missing_requirements', isTransferable: false }; + + render( + , + ); + + await waitFor(() => { + expect(mockNavigateToSignIn).toHaveBeenCalled(); + }); + }); + + it('activates existing session from signIn.existingSession and navigates to app', async () => { + mockSignIn = { + status: 'needs_identifier', + isTransferable: false, + existingSession: { sessionId: 'sess_existing_1' }, + }; + mockSignUp = { status: 'missing_requirements', isTransferable: false }; + + render( + , + ); + + await waitFor(() => { + expect(mockSetActive).toHaveBeenCalledWith({ + session: 'sess_existing_1', + navigate: expect.any(Function), + }); + expect(mockNavigateToApp).toHaveBeenCalled(); + }); + }); + + it('activates existing session from signUp.existingSession and navigates to app', async () => { + mockSignIn = { status: 'needs_identifier', isTransferable: false }; + mockSignUp = { + status: 'missing_requirements', + isTransferable: false, + existingSession: { sessionId: 'sess_existing_2' }, + }; + + render( + , + ); + + await waitFor(() => { + expect(mockSetActive).toHaveBeenCalledWith({ + session: 'sess_existing_2', + navigate: expect.any(Function), + }); + expect(mockNavigateToApp).toHaveBeenCalled(); + }); + }); + + it('does not run effect twice due to hasRun ref', async () => { + mockSignIn = { status: 'complete' }; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mockSignInFinalize).toHaveBeenCalledTimes(1); + }); + + rerender( + , + ); + + await waitFor(() => { + expect(mockSignInFinalize).toHaveBeenCalledTimes(1); + }); + }); +});