Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';

import { bindCreateFixtures } from '@/test/create-fixtures';
import { render, waitFor } from '@/test/utils';
Expand Down Expand Up @@ -102,6 +102,30 @@ describe('UserButton', () => {
expect(fixtures.router.navigate).toHaveBeenCalledWith('/after-sign-out');
});

it('calls signOutCallback when "Sign out" is clicked and signOutCallback prop is passed', async () => {
const { wrapper, fixtures, props } = await createFixtures(f => {
f.withUser({
first_name: 'First',
last_name: 'Last',
username: 'username1',
email_addresses: ['test@clerk.com'],
});
});

const signOutCallback = vi.fn();
fixtures.clerk.signOut.mockImplementation(callback => callback());
props.setProps({ signOutCallback });

const { getByText, getByRole, userEvent } = render(<UserButton />, { wrapper });
await userEvent.click(getByRole('button', { name: 'Open user button' }));
await userEvent.click(getByText('Sign out'));

expect(fixtures.clerk.signOut).toHaveBeenCalledWith(signOutCallback);
expect(signOutCallback).toHaveBeenCalled();
// Should not navigate when callback is provided
expect(fixtures.router.navigate).not.toHaveBeenCalled();
});

it.todo('navigates to sign in url when "Add account" is clicked');

describe('Multi Session Popover', () => {
Expand Down Expand Up @@ -177,6 +201,44 @@ describe('UserButton', () => {
expect(fixtures.router.navigate).toHaveBeenCalledWith('/');
});
});

it('calls signOutCallback in multi-session mode when "Sign out of all accounts" is clicked', async () => {
const { wrapper, fixtures, props } = await createFixtures(initConfig);
const signOutCallback = vi.fn();
fixtures.clerk.signOut.mockImplementationOnce(callback => {
return Promise.resolve(callback());
});
props.setProps({ signOutCallback });

const { getByText, getByRole, userEvent } = render(<UserButton />, { wrapper });
await userEvent.click(getByRole('button', { name: 'Open user button' }));
await userEvent.click(getByText('Sign out of all accounts'));
await waitFor(() => {
expect(fixtures.clerk.signOut).toHaveBeenCalledWith(signOutCallback);
expect(signOutCallback).toHaveBeenCalled();
// Should not navigate when callback is provided
expect(fixtures.router.navigate).not.toHaveBeenCalled();
});
});

it('calls signOutCallback in multi-session mode when signing out of a single session', async () => {
const { wrapper, fixtures, props } = await createFixtures(initConfig);
const signOutCallback = vi.fn();
fixtures.clerk.signOut.mockImplementationOnce(callback => {
return Promise.resolve(callback());
});
props.setProps({ signOutCallback });

const { getByText, getByRole, userEvent } = render(<UserButton />, { wrapper });
await userEvent.click(getByRole('button', { name: 'Open user button' }));
await userEvent.click(getByText('Sign out'));
await waitFor(() => {
expect(fixtures.clerk.signOut).toHaveBeenCalledWith(signOutCallback, { sessionId: '0' });
expect(signOutCallback).toHaveBeenCalled();
// Should not navigate when callback is provided
expect(fixtures.clerk.redirectWithAuth).not.toHaveBeenCalled();
});
});
});

describe('UserButtonTopLevelIdentifier', () => {
Expand Down
7 changes: 4 additions & 3 deletions packages/clerk-js/src/ui/contexts/components/UserButton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const useUserButtonContext = () => {
throw new Error('Clerk: useUserButtonContext called outside of the mounted UserButton component.');
}

const { componentName, customMenuItems, ...ctx } = context;
const { componentName, customMenuItems, signOutCallback, ...ctx } = context;

const signInUrl = ctx.signInUrl || options.signInUrl || displayConfig.signInUrl;
const userProfileUrl = ctx.userProfileUrl || displayConfig.userProfileUrl;
Expand All @@ -31,7 +31,7 @@ export const useUserButtonContext = () => {
}

const afterSignOutUrl = ctx.afterSignOutUrl || clerk.buildAfterSignOutUrl();
const navigateAfterSignOut = () => navigate(afterSignOutUrl);
const navigateAfterSignOut = signOutCallback || (() => navigate(afterSignOutUrl));

if (ctx.afterSignOutUrl) {
deprecatedObjectProperty(
Expand All @@ -42,7 +42,8 @@ export const useUserButtonContext = () => {
}
const afterMultiSessionSingleSignOutUrl =
ctx.afterMultiSessionSingleSignOutUrl || clerk.buildAfterMultiSessionSingleSignOutUrl();
const navigateAfterMultiSessionSingleSignOut = () => clerk.redirectWithAuth(afterMultiSessionSingleSignOutUrl);
const navigateAfterMultiSessionSingleSignOut =
signOutCallback || (() => clerk.redirectWithAuth(afterMultiSessionSingleSignOutUrl));

const afterSwitchSessionUrl = ctx.afterSwitchSessionUrl || displayConfig.afterSwitchSessionUrl;

Expand Down
14 changes: 10 additions & 4 deletions packages/types/src/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,10 @@

export type ListenerCallback = (emission: Resources) => void;
export type UnsubscribeCallback = () => void;
export type BeforeEmitCallback = (session?: SignedInSessionResource | null) => void | Promise<any>;
export type SetActiveNavigate = ({ session }: { session: SessionResource }) => void | Promise<unknown>;
export type BeforeEmitCallback = (session?: SignedInSessionResource | null) => undefined | Promise<any>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use Promise<unknown> instead of Promise<any> for consistency.

The return type uses Promise<any> which is less type-safe than Promise<unknown>. Line 124 (SetActiveNavigate) uses Promise<unknown>, so this should follow the same pattern for consistency.

Apply this diff:

-export type BeforeEmitCallback = (session?: SignedInSessionResource | null) => undefined | Promise<any>;
+export type BeforeEmitCallback = (session?: SignedInSessionResource | null) => undefined | Promise<unknown>;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export type BeforeEmitCallback = (session?: SignedInSessionResource | null) => undefined | Promise<any>;
export type BeforeEmitCallback = (session?: SignedInSessionResource | null) => undefined | Promise<unknown>;
🤖 Prompt for AI Agents
packages/types/src/clerk.ts around line 123: the BeforeEmitCallback type
currently returns undefined | Promise<any>; update it to use undefined |
Promise<unknown> for consistency with SetActiveNavigate and to improve type
safety — change the return type from Promise<any> to Promise<unknown> so the
type becomes (session?: SignedInSessionResource | null) => undefined |
Promise<unknown>.

export type SetActiveNavigate = ({ session }: { session: SessionResource }) => undefined | Promise<unknown>;

export type SignOutCallback = () => void | Promise<any>;
export type SignOutCallback = () => undefined | Promise<any>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use Promise<unknown> instead of Promise<any> for consistency.

The return type uses Promise<any> which is less type-safe. Line 124 uses Promise<unknown>, so this should match for consistency.

Apply this diff:

-export type SignOutCallback = () => undefined | Promise<any>;
+export type SignOutCallback = () => undefined | Promise<unknown>;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export type SignOutCallback = () => undefined | Promise<any>;
export type SignOutCallback = () => undefined | Promise<unknown>;
🤖 Prompt for AI Agents
In packages/types/src/clerk.ts around line 126, the SignOutCallback return type
currently uses Promise<any>; change it to Promise<unknown> to match the
project's type-safety convention and be consistent with line 124. Update the
type alias so the callback returns undefined | Promise<unknown> instead of
undefined | Promise<any>.


export type SignOutOptions = {
/**
Expand Down Expand Up @@ -944,7 +944,7 @@

export type HandleSamlCallbackParams = HandleOAuthCallbackParams;

export type CustomNavigation = (to: string, options?: NavigateOptions) => Promise<unknown> | void;
export type CustomNavigation = (to: string, options?: NavigateOptions) => Promise<unknown> | undefined;

export type ClerkThemeOptions = DeepSnakeToCamel<DeepPartial<DisplayThemeJSON>>;

Expand Down Expand Up @@ -1175,7 +1175,7 @@
*/
windowNavigate: (to: URL | string) => void;
},
) => Promise<unknown> | unknown;

Check warning on line 1178 in packages/types/src/clerk.ts

View workflow job for this annotation

GitHub Actions / Static analysis

'unknown' overrides all other types in this union type

export type WithoutRouting<T> = Omit<T, 'path' | 'routing'>;

Expand Down Expand Up @@ -1640,6 +1640,12 @@
* Provide custom menu actions and links to be rendered inside the UserButton.
*/
customMenuItems?: CustomMenuItem[];

/**
* Callback to be executed after the user signs out. Overrides the default navigation behavior.
* If provided, this callback will be used instead of navigating to `afterSignOutUrl`.
*/
signOutCallback?: SignOutCallback;
};

export type UserAvatarProps = {
Expand Down
Loading