Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .changeset/funny-monkeys-brush.md
Original file line number Diff line number Diff line change
@@ -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 `<ClerkProvider />` or `clerk.load()` call.

```js
// React
<ClerkProvider experimental={{ persistClient: true }} />

// Vanilla JS
await clerk.load({ experimental: { persistClient: true } })
```
5 changes: 5 additions & 0 deletions integration/presets/envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -87,6 +91,7 @@ const withDynamicKeys = withEmailCodes
export const envs = {
base,
withEmailCodes,
withEmailCodes_persist_client,
withEmailLinks,
withCustomRoles,
withEmailCodesQuickstart,
Expand Down
6 changes: 6 additions & 0 deletions integration/presets/longRunningApps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
15 changes: 15 additions & 0 deletions integration/templates/next-app-router/src/app/client-id.tsx
Original file line number Diff line number Diff line change
@@ -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 && <p data-clerk-id>{clerk?.client?.id}</p>}
{clerk?.client?.lastActiveSessionId && <p data-clerk-session>{clerk?.client?.lastActiveSessionId}</p>}
</>
);
}
6 changes: 5 additions & 1 deletion integration/templates/next-app-router/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ export const metadata = {

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<ClerkProvider
experimental={{
persistClient: process.env.NEXT_PUBLIC_EXPERIMENTAL_PERSIST_CLIENT === 'true',
}}
>
<html lang='en'>
<body className={inter.className}>{children}</body>
</html>
Expand Down
2 changes: 2 additions & 0 deletions integration/templates/next-app-router/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
<UserButton />
<ClientId />
<SignedIn>SignedIn</SignedIn>
<SignedOut>SignedOut</SignedOut>
<Protect fallback={'SignedOut from protect'}>SignedIn from protect</Protect>
Expand Down
2 changes: 2 additions & 0 deletions integration/templates/react-vite/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
<UserButton afterSignOutUrl={'/'} />
<OrganizationSwitcher />
<ClientId />
<SignedOut>SignedOut</SignedOut>
<SignedIn>SignedIn</SignedIn>
</main>
Expand Down
14 changes: 14 additions & 0 deletions integration/templates/react-vite/src/client-id.tsx
Original file line number Diff line number Diff line change
@@ -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 && <p data-clerk-id>{clerk?.client?.id}</p>}
{clerk?.client?.lastActiveSessionId && <p data-clerk-session>{clerk?.client?.lastActiveSessionId}</p>}
</>
);
}
3 changes: 3 additions & 0 deletions integration/templates/react-vite/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}}
>
<Outlet />
</ClerkProvider>
Expand Down
66 changes: 65 additions & 1 deletion integration/tests/sign-out-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test } from '@playwright/test';
import { expect, test } from '@playwright/test';

import { appConfigs } from '../presets';
import type { FakeUser } from '../testUtils';
Expand Down Expand Up @@ -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] })(
Copy link
Member

Choose a reason for hiding this comment

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

nice tests 👏

'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);
});
},
);
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
7 changes: 7 additions & 0 deletions packages/clerk-js/src/core/resources/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -65,6 +66,12 @@ export class Client extends BaseResource implements ClientResource {
});
}

removeSessions(): Promise<ClientResource> {
return this._baseDelete({
path: this.path() + '/sessions',
}) as unknown as Promise<ClientResource>;
}

clearCache(): void {
return this.sessions.forEach(s => s.clearCache());
}
Expand Down
10 changes: 10 additions & 0 deletions packages/types/src/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>
>;
};

export interface NavigateOptions {
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface ClientResource extends ClerkResource {
isNew: () => boolean;
create: () => Promise<ClientResource>;
destroy: () => Promise<void>;
removeSessions: () => Promise<ClientResource>;
clearCache: () => void;
lastActiveSessionId: string | null;
createdAt: Date | null;
Expand Down