diff --git a/.changeset/selfish-flies-care.md b/.changeset/selfish-flies-care.md
new file mode 100644
index 00000000000..a9302afac2a
--- /dev/null
+++ b/.changeset/selfish-flies-care.md
@@ -0,0 +1,18 @@
+---
+'@clerk/clerk-js': minor
+'@clerk/clerk-react': minor
+'@clerk/types': minor
+---
+
+Update `@clerk/clerk-js` and `@clerk/clerk-react` to support the following examples:
+
+```typescript
+Clerk.signOut({ redirectUrl: '/' })
+
+
+// uses Clerk.signOut({ redirectUrl: '/' })
+
+// uses Clerk.signOut({ redirectUrl: '/after' })
+
+// uses Clerk.signOut({ redirectUrl: '/after' })
+```
\ No newline at end of file
diff --git a/packages/clerk-js/src/core/clerk.test.ts b/packages/clerk-js/src/core/clerk.test.ts
index 5f490e3537f..f0e3ea1a2a3 100644
--- a/packages/clerk-js/src/core/clerk.test.ts
+++ b/packages/clerk-js/src/core/clerk.test.ts
@@ -443,7 +443,10 @@ describe('Clerk singleton', () => {
await sut.signOut();
await waitFor(() => {
expect(mockClientDestroy).toHaveBeenCalled();
- expect(sut.setActive).toHaveBeenCalledWith({ session: null });
+ expect(sut.setActive).toHaveBeenCalledWith({
+ session: null,
+ beforeEmit: expect.any(Function),
+ });
});
});
@@ -463,7 +466,7 @@ describe('Clerk singleton', () => {
await waitFor(() => {
expect(mockClientDestroy).toHaveBeenCalled();
expect(mockSession1.remove).not.toHaveBeenCalled();
- expect(sut.setActive).toHaveBeenCalledWith({ session: null });
+ expect(sut.setActive).toHaveBeenCalledWith({ session: null, beforeEmit: expect.any(Function) });
});
});
@@ -485,6 +488,7 @@ describe('Clerk singleton', () => {
expect(mockClientDestroy).not.toHaveBeenCalled();
expect(sut.setActive).not.toHaveBeenCalledWith({
session: null,
+ beforeEmit: expect.any(Function),
});
});
});
@@ -505,7 +509,29 @@ describe('Clerk singleton', () => {
await waitFor(() => {
expect(mockSession1.remove).toHaveBeenCalled();
expect(mockClientDestroy).not.toHaveBeenCalled();
- expect(sut.setActive).toHaveBeenCalledWith({ session: null });
+ expect(sut.setActive).toHaveBeenCalledWith({ session: null, beforeEmit: expect.any(Function) });
+ });
+ });
+
+ it('removes and signs out the session and redirects to the provided redirectUrl ', async () => {
+ mockClientFetch.mockReturnValue(
+ Promise.resolve({
+ activeSessions: [mockSession1, mockSession2],
+ sessions: [mockSession1, mockSession2],
+ destroy: mockClientDestroy,
+ }),
+ );
+
+ const sut = new Clerk(productionPublishableKey);
+ sut.setActive = jest.fn(({ beforeEmit }) => beforeEmit());
+ sut.navigate = jest.fn();
+ await sut.load();
+ await sut.signOut({ sessionId: '1', redirectUrl: '/after-sign-out' });
+ await waitFor(() => {
+ expect(mockSession1.remove).toHaveBeenCalled();
+ expect(mockClientDestroy).not.toHaveBeenCalled();
+ expect(sut.setActive).toHaveBeenCalledWith({ session: null, beforeEmit: expect.any(Function) });
+ expect(sut.navigate).toHaveBeenCalledWith('/after-sign-out');
});
});
});
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index d988a494bd8..463b937d910 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -133,6 +133,7 @@ const defaultOptions: ClerkOptions = {
signUpUrl: undefined,
afterSignInUrl: undefined,
afterSignUpUrl: undefined,
+ afterSignOutUrl: undefined,
};
export class Clerk implements ClerkInterface {
@@ -301,9 +302,12 @@ export class Clerk implements ClerkInterface {
if (!this.client || this.client.sessions.length === 0) {
return;
}
- const cb = typeof callbackOrOptions === 'function' ? callbackOrOptions : undefined;
const opts = callbackOrOptions && typeof callbackOrOptions === 'object' ? callbackOrOptions : options || {};
+ const redirectUrl = opts?.redirectUrl || this.buildAfterSignOutUrl();
+ const defaultCb = () => this.navigate(redirectUrl);
+ const cb = typeof callbackOrOptions === 'function' ? callbackOrOptions : defaultCb;
+
if (!opts.sessionId || this.client.activeSessions.length === 1) {
await this.client.destroy();
return this.setActive({
@@ -758,6 +762,14 @@ export class Clerk implements ClerkInterface {
return this.buildUrlWithAuth(this.#options.afterSignUpUrl);
}
+ public buildAfterSignOutUrl(): string {
+ if (!this.#options.afterSignOutUrl) {
+ return '/';
+ }
+
+ return this.buildUrlWithAuth(this.#options.afterSignOutUrl);
+ }
+
public buildCreateOrganizationUrl(): string {
if (!this.#environment || !this.#environment.displayConfig) {
return '';
@@ -848,6 +860,13 @@ export class Clerk implements ClerkInterface {
return;
};
+ public redirectToAfterSignOut = async (): Promise => {
+ if (inBrowser()) {
+ return this.navigate(this.buildAfterSignOutUrl());
+ }
+ return;
+ };
+
public handleEmailLinkVerification = async (
params: HandleEmailLinkVerificationParams,
customNavigate?: (to: string) => Promise,
diff --git a/packages/clerk-js/src/ui/components/UserButton/__tests__/UserButton.test.tsx b/packages/clerk-js/src/ui/components/UserButton/__tests__/UserButton.test.tsx
index a0228519fa6..f9f2bd3142c 100644
--- a/packages/clerk-js/src/ui/components/UserButton/__tests__/UserButton.test.tsx
+++ b/packages/clerk-js/src/ui/components/UserButton/__tests__/UserButton.test.tsx
@@ -59,10 +59,34 @@ describe('UserButton', () => {
email_addresses: ['test@clerk.com'],
});
});
+
+ fixtures.clerk.signOut.mockImplementationOnce(callback => callback());
+
+ const { getByText, getByRole, userEvent } = render(, { wrapper });
+ await userEvent.click(getByRole('button', { name: 'Open user button' }));
+ await userEvent.click(getByText('Sign out'));
+
+ expect(fixtures.router.navigate).toHaveBeenCalledWith('/');
+ });
+
+ it('redirects to afterSignOutUrl when "Sign out" is clicked and afterSignOutUrl 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'],
+ });
+ });
+
+ fixtures.clerk.signOut.mockImplementation(callback => callback());
+ props.setProps({ afterSignOutUrl: '/after-sign-out' });
+
const { getByText, getByRole, userEvent } = render(, { wrapper });
await userEvent.click(getByRole('button', { name: 'Open user button' }));
await userEvent.click(getByText('Sign out'));
- expect(fixtures.clerk.signOut).toHaveBeenCalled();
+
+ expect(fixtures.router.navigate).toHaveBeenCalledWith('/after-sign-out');
});
it.todo('navigates to sign in url when "Add account" is clicked');
diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
index 5b0d548996e..5cde066a9e8 100644
--- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
+++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
@@ -232,7 +232,7 @@ export const useUserButtonContext = () => {
const afterMultiSessionSingleSignOutUrl = ctx.afterMultiSessionSingleSignOutUrl || displayConfig.afterSignOutOneUrl;
const navigateAfterMultiSessionSingleSignOut = () => clerk.redirectWithAuth(afterMultiSessionSingleSignOutUrl);
- const afterSignOutUrl = ctx.afterSignOutUrl || '/';
+ const afterSignOutUrl = ctx.afterSignOutUrl || clerk.buildAfterSignOutUrl();
const navigateAfterSignOut = () => navigate(afterSignOutUrl);
const afterSwitchSessionUrl = ctx.afterSwitchSessionUrl || displayConfig.afterSwitchSessionUrl;
diff --git a/packages/react/src/components/SignOutButton.tsx b/packages/react/src/components/SignOutButton.tsx
index f3ed4e611a3..19dd6f9bf7c 100644
--- a/packages/react/src/components/SignOutButton.tsx
+++ b/packages/react/src/components/SignOutButton.tsx
@@ -18,14 +18,7 @@ export const SignOutButton = withClerk(
children = normalizeWithDefaultValue(children, 'Sign out');
const child = assertSingleChild(children)('SignOutButton');
- const navigateToRedirectUrl = () => {
- return clerk.navigate(redirectUrl);
- };
-
- const clickHandler = () => {
- return clerk.signOut(navigateToRedirectUrl, signOutOptions);
- };
-
+ const clickHandler = () => clerk.signOut({ redirectUrl });
const wrappedChildClickHandler: React.MouseEventHandler = async e => {
await safeExecute((child as any).props.onClick)(e);
return clickHandler();
diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts
index f4b7df167e5..8f50d8fd228 100644
--- a/packages/react/src/isomorphicClerk.ts
+++ b/packages/react/src/isomorphicClerk.ts
@@ -75,6 +75,7 @@ type IsomorphicLoadedClerk = Without<
| 'buildOrganizationProfileUrl'
| 'buildAfterSignUpUrl'
| 'buildAfterSignInUrl'
+ | 'buildAfterSignOutUrl'
| 'buildUrlWithAuth'
| 'handleRedirectCallback'
| 'handleUnauthenticated'
@@ -115,6 +116,8 @@ type IsomorphicLoadedClerk = Without<
buildAfterSignInUrl: () => string | void;
// TODO: Align return type
buildAfterSignUpUrl: () => string | void;
+ // TODO: Align return type
+ buildAfterSignOutUrl: () => string | void;
// TODO: Align optional props
mountUserButton: (node: HTMLDivElement, props: UserButtonProps) => void;
mountOrganizationList: (node: HTMLDivElement, props: OrganizationListProps) => void;
@@ -278,6 +281,15 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
}
};
+ buildAfterSignOutUrl = (): string | void => {
+ const callback = () => this.clerkjs?.buildAfterSignOutUrl() || '';
+ if (this.clerkjs && this.#loaded) {
+ return callback();
+ } else {
+ this.premountMethodCalls.set('buildAfterSignOutUrl', callback);
+ }
+ };
+
buildUserProfileUrl = (): string | void => {
const callback = () => this.clerkjs?.buildUserProfileUrl() || '';
if (this.clerkjs && this.#loaded) {
@@ -835,6 +847,15 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
}
};
+ redirectToAfterSignOut = (): void => {
+ const callback = () => this.clerkjs?.redirectToAfterSignOut();
+ if (this.clerkjs && this.#loaded) {
+ callback();
+ } else {
+ this.premountMethodCalls.set('redirectToAfterSignOut', callback);
+ }
+ };
+
redirectToOrganizationProfile = async (): Promise => {
const callback = () => this.clerkjs?.redirectToOrganizationProfile();
if (this.clerkjs && this.#loaded) {
diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts
index bd5f4a4f4c2..59ec7047a65 100644
--- a/packages/types/src/clerk.ts
+++ b/packages/types/src/clerk.ts
@@ -39,6 +39,10 @@ export type SignOutOptions = {
* multi-session applications.
*/
sessionId?: string;
+ /**
+ * Specify a redirect URL to navigate after sign out is complete.
+ */
+ redirectUrl?: string;
};
export interface SignOut {
@@ -341,15 +345,20 @@ export interface Clerk {
buildOrganizationProfileUrl(): string;
/**
- * Returns the configured afterSignIn url of the instance.
+ * Returns the configured afterSignInUrl of the instance.
*/
buildAfterSignInUrl(): string;
/**
- * Returns the configured afterSignIn url of the instance.
+ * Returns the configured afterSignInUrl of the instance.
*/
buildAfterSignUpUrl(): string;
+ /**
+ * Returns the configured afterSignOutUrl of the instance.
+ */
+ buildAfterSignOutUrl(): string;
+
/**
*
* Redirects to the provided url after decorating it with the auth token for development instances.
@@ -397,6 +406,11 @@ export interface Clerk {
*/
redirectToAfterSignUp: () => void;
+ /**
+ * Redirects to the configured afterSignOut URL.
+ */
+ redirectToAfterSignOut: () => void;
+
/**
* Completes an OAuth or SAML redirection flow started by
* {@link Clerk.client.signIn.authenticateWithRedirect} or {@link Clerk.client.signUp.authenticateWithRedirect}
@@ -435,17 +449,7 @@ export interface Clerk {
handleUnauthenticated: () => Promise;
}
-export type HandleOAuthCallbackParams = {
- /**
- * Full URL or path to navigate after successful sign up.
- */
- afterSignUpUrl?: string | null;
-
- /**
- * Full URL or path to navigate after successful sign in.
- */
- afterSignInUrl?: string | null;
-
+export type HandleOAuthCallbackParams = AfterActionURLs & {
/**
* Full URL or path to navigate after successful sign in
* or sign up.
@@ -513,35 +517,34 @@ type ClerkOptionsNavigation = ClerkOptionsNavigationFn & {
routerDebug?: boolean;
};
-export type ClerkOptions = ClerkOptionsNavigation & {
- appearance?: Appearance;
- localization?: LocalizationResource;
- polling?: boolean;
- selectInitialSession?: (client: ClientResource) => ActiveSessionResource | null;
- /** Controls if ClerkJS will load with the standard browser setup using Clerk cookies */
- standardBrowser?: boolean;
- /** Optional support email for display in authentication screens */
- supportEmail?: string;
- touchSession?: boolean;
- signInUrl?: string;
- signUpUrl?: string;
- afterSignInUrl?: string;
- afterSignUpUrl?: string;
- allowedRedirectOrigins?: Array;
- isSatellite?: boolean | ((url: URL) => boolean);
-
- /**
- * Telemetry options
- */
- telemetry?:
- | false
- | {
- disabled?: boolean;
- debug?: boolean;
- };
+export type ClerkOptions = ClerkOptionsNavigation &
+ AfterActionURLs & {
+ appearance?: Appearance;
+ localization?: LocalizationResource;
+ polling?: boolean;
+ selectInitialSession?: (client: ClientResource) => ActiveSessionResource | null;
+ /** Controls if ClerkJS will load with the standard browser setup using Clerk cookies */
+ standardBrowser?: boolean;
+ /** Optional support email for display in authentication screens */
+ supportEmail?: string;
+ touchSession?: boolean;
+ signInUrl?: string;
+ signUpUrl?: string;
+ allowedRedirectOrigins?: Array;
+ isSatellite?: boolean | ((url: URL) => boolean);
- sdkMetadata?: SDKMetadata;
-};
+ /**
+ * Telemetry options
+ */
+ telemetry?:
+ | false
+ | {
+ disabled?: boolean;
+ debug?: boolean;
+ };
+
+ sdkMetadata?: SDKMetadata;
+ };
export interface NavigateOptions {
replace?: boolean;
@@ -572,7 +575,7 @@ export type SignUpInitialValues = {
username?: string;
};
-export type RedirectOptions = {
+type AfterActionURLs = {
/**
* Full URL or path to navigate after successful sign in.
*/
@@ -584,6 +587,13 @@ export type RedirectOptions = {
*/
afterSignUpUrl?: string | null;
+ /**
+ * Full URL or path to navigate after successful sign out.
+ */
+ afterSignOutUrl?: string | null;
+};
+
+export type RedirectOptions = AfterActionURLs & {
/**
* Full URL or path to navigate after successful sign in,
* or sign up.