diff --git a/.changeset/funny-monkeys-brush.md b/.changeset/funny-monkeys-brush.md
new file mode 100644
index 00000000000..9bdad19f66d
--- /dev/null
+++ b/.changeset/funny-monkeys-brush.md
@@ -0,0 +1,15 @@
+---
+"@clerk/types": minor
+"@clerk/clerk-js": minor
+---
+
+**Experimental:** Persist the Clerk client after signing out a user.
+This allows for matching a user's device with a client. To try out this new feature, enable it in your `` or `clerk.load()` call.
+
+```js
+// React
+
+
+// Vanilla JS
+await clerk.load({ experimental: { persistClient: true } })
+```
diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts
index 58bb086b1a5..e2a9f8030da 100644
--- a/integration/presets/envs.ts
+++ b/integration/presets/envs.ts
@@ -35,6 +35,10 @@ const withEmailCodes = base
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk)
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');
+const withEmailCodes_persist_client = withEmailCodes
+ .clone()
+ .setEnvVariable('public', 'EXPERIMENTAL_PERSIST_CLIENT', 'true');
+
const withEmailLinks = base
.clone()
.setId('withEmailLinks')
@@ -87,6 +91,7 @@ const withDynamicKeys = withEmailCodes
export const envs = {
base,
withEmailCodes,
+ withEmailCodes_persist_client,
withEmailLinks,
withCustomRoles,
withEmailCodesQuickstart,
diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts
index e166a148c72..718b2915ca6 100644
--- a/integration/presets/longRunningApps.ts
+++ b/integration/presets/longRunningApps.ts
@@ -19,9 +19,15 @@ export const createLongRunningApps = () => {
const configs = [
{ id: 'express.vite.withEmailCodes', config: express.vite, env: envs.withEmailCodes },
{ id: 'react.vite.withEmailCodes', config: react.vite, env: envs.withEmailCodes },
+ { id: 'react.vite.withEmailCodes_persist_client', config: react.vite, env: envs.withEmailCodes_persist_client },
{ id: 'react.vite.withEmailLinks', config: react.vite, env: envs.withEmailLinks },
{ id: 'remix.node.withEmailCodes', config: remix.remixNode, env: envs.withEmailCodes },
{ id: 'next.appRouter.withEmailCodes', config: next.appRouter, env: envs.withEmailCodes },
+ {
+ id: 'next.appRouter.withEmailCodes_persist_client',
+ config: next.appRouter,
+ env: envs.withEmailCodes_persist_client,
+ },
{ id: 'next.appRouter.withCustomRoles', config: next.appRouter, env: envs.withCustomRoles },
{ id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart },
{ id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes },
diff --git a/integration/templates/next-app-router/src/app/client-id.tsx b/integration/templates/next-app-router/src/app/client-id.tsx
new file mode 100644
index 00000000000..d376dbfdc1e
--- /dev/null
+++ b/integration/templates/next-app-router/src/app/client-id.tsx
@@ -0,0 +1,15 @@
+'use client';
+import { useClerk, useSession } from '@clerk/nextjs';
+import React from 'react';
+
+export function ClientId() {
+ const clerk = useClerk();
+ // For re-rendering
+ useSession();
+ return (
+ <>
+ {clerk?.client?.id &&
{clerk?.client?.id}
}
+ {clerk?.client?.lastActiveSessionId && {clerk?.client?.lastActiveSessionId}
}
+ >
+ );
+}
diff --git a/integration/templates/next-app-router/src/app/layout.tsx b/integration/templates/next-app-router/src/app/layout.tsx
index 29ddd566bdb..d73009ca875 100644
--- a/integration/templates/next-app-router/src/app/layout.tsx
+++ b/integration/templates/next-app-router/src/app/layout.tsx
@@ -11,7 +11,11 @@ export const metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
-
+
{children}
diff --git a/integration/templates/next-app-router/src/app/page.tsx b/integration/templates/next-app-router/src/app/page.tsx
index db74677ae17..72108e95801 100644
--- a/integration/templates/next-app-router/src/app/page.tsx
+++ b/integration/templates/next-app-router/src/app/page.tsx
@@ -1,10 +1,12 @@
import { SignedIn, SignedOut, SignIn, UserButton, Protect } from '@clerk/nextjs';
import Link from 'next/link';
+import { ClientId } from './client-id';
export default function Home() {
return (
+
SignedIn
SignedOut
SignedIn from protect
diff --git a/integration/templates/react-vite/src/App.tsx b/integration/templates/react-vite/src/App.tsx
index 47b38346dec..98e3530af67 100644
--- a/integration/templates/react-vite/src/App.tsx
+++ b/integration/templates/react-vite/src/App.tsx
@@ -1,11 +1,13 @@
import { OrganizationSwitcher, SignedIn, SignedOut, UserButton } from '@clerk/clerk-react';
import React from 'react';
+import { ClientId } from './client-id';
function App() {
return (
+
SignedOut
SignedIn
diff --git a/integration/templates/react-vite/src/client-id.tsx b/integration/templates/react-vite/src/client-id.tsx
new file mode 100644
index 00000000000..49a76a45dcb
--- /dev/null
+++ b/integration/templates/react-vite/src/client-id.tsx
@@ -0,0 +1,14 @@
+import { useClerk, useSession } from '@clerk/clerk-react';
+import React from 'react';
+
+export function ClientId() {
+ const clerk = useClerk();
+ // For re-rendering
+ useSession();
+ return (
+ <>
+ {clerk?.client?.id && {clerk?.client?.id}
}
+ {clerk?.client?.lastActiveSessionId && {clerk?.client?.lastActiveSessionId}
}
+ >
+ );
+}
diff --git a/integration/templates/react-vite/src/main.tsx b/integration/templates/react-vite/src/main.tsx
index 224c68b1496..4b151e3f2f3 100644
--- a/integration/templates/react-vite/src/main.tsx
+++ b/integration/templates/react-vite/src/main.tsx
@@ -18,6 +18,9 @@ const Root = () => {
clerkJSUrl={import.meta.env.VITE_CLERK_JS_URL as string}
routerPush={(to: string) => navigate(to)}
routerReplace={(to: string) => navigate(to, { replace: true })}
+ experimental={{
+ persistClient: import.meta.env.VITE_EXPERIMENTAL_PERSIST_CLIENT === 'true',
+ }}
>
diff --git a/integration/tests/sign-out-smoke.test.ts b/integration/tests/sign-out-smoke.test.ts
index 0a317438249..27381295be3 100644
--- a/integration/tests/sign-out-smoke.test.ts
+++ b/integration/tests/sign-out-smoke.test.ts
@@ -1,4 +1,4 @@
-import { test } from '@playwright/test';
+import { expect, test } from '@playwright/test';
import { appConfigs } from '../presets';
import type { FakeUser } from '../testUtils';
@@ -45,4 +45,68 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('sign out
await mainTab.po.expect.toBeSignedOut();
});
+
+ test('sign out destroying client', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.po.signIn.goTo();
+ await u.po.signIn.setIdentifier(fakeUser.email);
+ await u.po.signIn.continue();
+ await u.po.signIn.setPassword(fakeUser.password);
+ await u.po.signIn.continue();
+ await u.po.expect.toBeSignedIn();
+ await u.page.goToAppHome();
+
+ await u.page.waitForSelector('p[data-clerk-id]', { state: 'attached' });
+
+ await u.page.evaluate(async () => {
+ await window.Clerk.signOut();
+ });
+
+ await u.po.expect.toBeSignedOut();
+ await u.page.waitForSelector('p[data-clerk-id]', { state: 'detached' });
+ await u.page.waitForSelector('p[data-clerk-session]', { state: 'detached' });
+ });
});
+
+testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes_persist_client] })(
+ 'sign out with persistClient smoke test @generic',
+ ({ app }) => {
+ test.describe.configure({ mode: 'serial' });
+
+ let fakeUser: FakeUser;
+
+ test.beforeAll(async () => {
+ const u = createTestUtils({ app });
+ fakeUser = u.services.users.createFakeUser();
+ await u.services.users.createBapiUser(fakeUser);
+ });
+
+ test.afterAll(async () => {
+ await fakeUser.deleteIfExists();
+ await app.teardown();
+ });
+
+ test('sign out persisting client', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.po.signIn.goTo();
+ await u.po.signIn.setIdentifier(fakeUser.email);
+ await u.po.signIn.continue();
+ await u.po.signIn.setPassword(fakeUser.password);
+ await u.po.signIn.continue();
+ await u.po.expect.toBeSignedIn();
+ await u.page.goToAppHome();
+ const client_id_element = await u.page.waitForSelector('p[data-clerk-id]', { state: 'attached' });
+ const client_id = await client_id_element.innerHTML();
+
+ await u.page.evaluate(async () => {
+ await window.Clerk.signOut();
+ });
+
+ await u.po.expect.toBeSignedOut();
+ await u.page.waitForSelector('p[data-clerk-session]', { state: 'detached' });
+
+ const client_id_after_sign_out = await u.page.locator('p[data-clerk-id]').innerHTML();
+ expect(client_id).toEqual(client_id_after_sign_out);
+ });
+ },
+);
diff --git a/package.json b/package.json
index feff8ce65e3..5d86e1126b7 100644
--- a/package.json
+++ b/package.json
@@ -36,7 +36,7 @@
"test:integration:deployment:nextjs": "npx playwright test --config integration/playwright.deployments.config.ts",
"test:integration:elements": "E2E_APP_ID=elements.* npm run test:integration:base -- --grep @elements",
"test:integration:express": "E2E_APP_ID=express.* npm run test:integration:base -- --grep @express",
- "test:integration:generic": "E2E_APP_ID=react.vite.*,next.appRouter.withEmailCodes npm run test:integration:base -- --grep @generic",
+ "test:integration:generic": "E2E_APP_ID=react.vite.*,next.appRouter.withEmailCodes* npm run test:integration:base -- --grep @generic",
"test:integration:nextjs": "E2E_APP_ID=next.appRouter.* npm run test:integration:base -- --grep @nextjs",
"test:integration:astro": "E2E_APP_ID=astro.* npm run test:integration:base -- --grep @astro",
"test:integration:expo-web": "E2E_APP_ID=expo.expo-web npm run test:integration:base -- --grep @expo-web",
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index 84c3281b184..111d8690d9c 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -333,7 +333,12 @@ export class Clerk implements ClerkInterface {
const cb = typeof callbackOrOptions === 'function' ? callbackOrOptions : defaultCb;
if (!opts.sessionId || this.client.activeSessions.length === 1) {
- await this.client.destroy();
+ if (this.#options.experimental?.persistClient) {
+ await this.client.removeSessions();
+ } else {
+ await this.client.destroy();
+ }
+
return this.setActive({
session: null,
beforeEmit: ignoreEventValue(cb),
diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts
index e00a30bac74..28f0ccc8cc4 100644
--- a/packages/clerk-js/src/core/resources/Client.ts
+++ b/packages/clerk-js/src/core/resources/Client.ts
@@ -56,6 +56,7 @@ export class Client extends BaseResource implements ClientResource {
// TODO: Make it restful by introducing a DELETE /client/:id endpoint
return this._baseDelete({ path: '/client' }).then(() => {
SessionTokenCache.clear();
+ this.id = '';
this.sessions = [];
this.signUp = new SignUp(null);
this.signIn = new SignIn(null);
@@ -65,6 +66,12 @@ export class Client extends BaseResource implements ClientResource {
});
}
+ removeSessions(): Promise {
+ return this._baseDelete({
+ path: this.path() + '/sessions',
+ }) as unknown as Promise;
+ }
+
clearCache(): void {
return this.sessions.forEach(s => s.clearCache());
}
diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts
index 3bf7c2d03ac..a5bb23b5c22 100644
--- a/packages/types/src/clerk.ts
+++ b/packages/types/src/clerk.ts
@@ -650,6 +650,16 @@ export type ClerkOptions = ClerkOptionsNavigation &
};
sdkMetadata?: SDKMetadata;
+
+ /**
+ * Enable experimental flags to gain access to new features. These flags are not guaranteed to be stable and may change drastically in between patch or minor versions.
+ */
+ experimental?: Autocomplete<
+ {
+ persistClient: boolean;
+ },
+ Record
+ >;
};
export interface NavigateOptions {
diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts
index f4f85e4c398..4326520dd0a 100644
--- a/packages/types/src/client.ts
+++ b/packages/types/src/client.ts
@@ -11,6 +11,7 @@ export interface ClientResource extends ClerkResource {
isNew: () => boolean;
create: () => Promise;
destroy: () => Promise;
+ removeSessions: () => Promise;
clearCache: () => void;
lastActiveSessionId: string | null;
createdAt: Date | null;