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
6 changes: 6 additions & 0 deletions .changeset/quiet-devices-sign-out.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/shared': patch
'@clerk/mosaic': patch
---

Fix cached active device sessions remaining after sign-out when the UserProfile device section has already been closed.
Original file line number Diff line number Diff line change
Expand Up @@ -217,21 +217,32 @@ describe('Active devices', () => {
}
});

it('drops cached devices on sign out', async () => {
it.each(['mounted', 'unmounted'] as const)('drops cached devices on sign out when %s', async state => {
serveDevices([device('sess_current', 'active')]);
const view = await renderWithClerk(<UserProfileActiveDevicesSection />);
expect(await screen.findByText('This device')).toBeVisible();
const queryClient = getClerkQueryClient();
const cachedDevices = () =>
getClerkQueryClient()
queryClient
?.getQueryCache()
.getAll()
.filter(query => query.state.data !== undefined) ?? [];
expect(cachedDevices()).toHaveLength(1);
const cachedKeys = cachedDevices().map(query => query.queryKey);

if (state === 'unmounted') {
view.unmount();
expect(cachedDevices()).toHaveLength(1);
}

await act(() => view.clerk.signOut());

expect(view.container).toBeEmptyDOMElement();
expect(getClerkQueryClient()).toBe(queryClient);
expect(cachedDevices()).toHaveLength(0);
for (const queryKey of cachedKeys) {
expect(queryClient?.getQueryCache().find({ queryKey, exact: true })).toBeUndefined();
}
});

it('shows signed-in sessions and signs out another device inside the security panel', async () => {
Expand Down
11 changes: 3 additions & 8 deletions packages/mosaic/src/queries/use-user-sessions-query.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
__internal_createCacheKeys,
__internal_useClearQueriesOnSignOut,
__internal_useClerkQuery,
__internal_usePersistentQueryCleanupOnSignOut,
useSession,
useUser,
} from '@clerk/shared/react';
Expand All @@ -15,7 +15,7 @@ export function useUserSessionsQuery() {
const userId = user?.id;
const sessionId = session?.id;

const { queryKey, stableKey, authenticated } = useMemo(
const { queryKey, stableKey } = useMemo(
() =>
__internal_createCacheKeys({
stablePrefix: USER_SESSIONS_KEY,
Expand All @@ -26,12 +26,7 @@ export function useUserSessionsQuery() {
[userId, sessionId],
);

// Only runs while this hook is mounted, matching the shared hooks. Sign-out after unmount leaves the entry until it is garbage collected.
__internal_useClearQueriesOnSignOut({
isSignedOut: user === null,
authenticated,
stableKeys: stableKey,
});
__internal_usePersistentQueryCleanupOnSignOut(stableKey);

return __internal_useClerkQuery({
queryKey,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { renderHook } from '@testing-library/react';
import { StrictMode } from 'react';
import { afterEach, expect, it, vi } from 'vitest';

import type { Clerk, Resources } from '@/types';

import { __createClerkTestQueryClient, __resetClerkQueryClientForTest } from '../../query/clerk-query-client';
import { usePersistentQueryCleanupOnSignOut } from '../useClearQueriesOnSignOut';

const { useClerkInstanceContext } = vi.hoisted(() => ({
useClerkInstanceContext: vi.fn<() => Pick<Clerk, 'addListener' | 'user'>>(),
}));

vi.mock('../../contexts', () => ({ useClerkInstanceContext }));

afterEach(() => {
__resetClerkQueryClientForTest();
vi.clearAllMocks();
});

it('keeps one sign-out subscription after StrictMode setup and unmount', () => {
const client = __createClerkTestQueryClient();
const unsubscribe = vi.fn();
const clerk = {
user: undefined,
addListener: vi.fn<Clerk['addListener']>(() => unsubscribe),
};
useClerkInstanceContext.mockReturnValue(clerk);
client.setQueryData(['sessions', true], 'private');

const { unmount } = renderHook(() => usePersistentQueryCleanupOnSignOut('sessions'), { wrapper: StrictMode });
unmount();

expect(clerk.addListener).toHaveBeenCalledTimes(1);
expect(unsubscribe).not.toHaveBeenCalled();
const callback = clerk.addListener.mock.calls[0]?.[0];
callback?.({
user: null,
get client(): Resources['client'] {
throw new Error('Sign-out cleanup must not read the client resource');
},
});
expect(client.getQueryState(['sessions', true])).toBeUndefined();
});
5 changes: 4 additions & 1 deletion packages/shared/src/react/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ export type {
} from './useOrganizationSSOBypassAllowlist';

export { createCacheKeys as __internal_createCacheKeys } from './createCacheKeys';
export { useClearQueriesOnSignOut as __internal_useClearQueriesOnSignOut } from './useClearQueriesOnSignOut';
export {
useClearQueriesOnSignOut as __internal_useClearQueriesOnSignOut,
usePersistentQueryCleanupOnSignOut as __internal_usePersistentQueryCleanupOnSignOut,
} from './useClearQueriesOnSignOut';
export { useClerkQuery as __internal_useClerkQuery } from '../query/useQuery';

export { useUserBase as __internal_useUserBase } from './base/useUserBase';
Expand Down
13 changes: 13 additions & 0 deletions packages/shared/src/react/hooks/useClearQueriesOnSignOut.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useEffect, useRef } from 'react';

import { useClerkInstanceContext } from '../contexts';
import { registerQueryCleanupOnSignOut } from '../query/clerk-query-client';
import { useClerkQueryClient } from '../query/use-clerk-query-client';
import { usePreviousValue } from './usePreviousValue';

Expand Down Expand Up @@ -59,3 +61,14 @@ export function useClearQueriesOnSignOut(options: ClearQueriesOnSignOutOptions)
}
}, [authenticated, isSignedOut, previousIsSignedIn, queryClient]);
}

export function usePersistentQueryCleanupOnSignOut(stableKey: string): void {
const clerk = useClerkInstanceContext();
const [queryClient, isLoaded] = useClerkQueryClient();

useEffect(() => {
if (isLoaded) {
registerQueryCleanupOnSignOut(clerk, stableKey);
}
}, [clerk, isLoaded, queryClient, stableKey]);
}
145 changes: 145 additions & 0 deletions packages/shared/src/react/query/__tests__/clerk-query-client.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { QueryClient } from '@tanstack/query-core';
import { afterEach, describe, expect, it, vi } from 'vitest';

import type { Clerk, Resources } from '@/types';
import { createDeferredPromise } from '@/utils/createDeferredPromise';

import {
__createClerkTestQueryClient,
__resetClerkQueryClientForTest,
__setClerkQueryClientForTest,
getClerkQueryClient,
registerQueryCleanupOnSignOut,
} from '../clerk-query-client';

afterEach(() => {
Expand Down Expand Up @@ -82,3 +86,144 @@ describe('__createClerkTestQueryClient', () => {
expect(defaults?.refetchOnMount).toBe(false);
});
});

function resources(user: Clerk['user']): Resources {
return {
user,
get client(): Resources['client'] {
throw new Error('Sign-out cleanup must not read the client resource');
},
};
}

function createClerkListener(user: Clerk['user'] = undefined) {
const listeners = new Set<Parameters<Clerk['addListener']>[0]>();
const unsubscribe = vi.fn();
const clerk = {
user,
addListener: vi.fn<Clerk['addListener']>(callback => {
listeners.add(callback);
callback(resources(clerk.user));
return () => {
listeners.delete(callback);
unsubscribe();
};
}),
};
return {
clerk,
unsubscribe,
emit(user: Clerk['user']) {
clerk.user = user;
listeners.forEach(callback => callback(resources(user)));
},
};
}

describe('registerQueryCleanupOnSignOut', () => {
it('clears registered authenticated queries on null but ignores unresolved user state', () => {
const client = __createClerkTestQueryClient();
const { clerk, emit } = createClerkListener();
client.setQueryData(['sessions', true], 'private');
client.setQueryData(['sessions', false], 'public');
client.setQueryData(['billing', true], 'unrelated');

registerQueryCleanupOnSignOut(clerk, 'sessions');
emit(undefined);
expect(client.getQueryData(['sessions', true])).toBe('private');

emit(null);
expect(client.getQueryState(['sessions', true])).toBeUndefined();
expect(client.getQueryData(['sessions', false])).toBe('public');
expect(client.getQueryData(['billing', true])).toBe('unrelated');
});

it('clears stale entries on initial null and when another prefix registers after sign-out', () => {
const client = __createClerkTestQueryClient();
const { clerk } = createClerkListener(null);
client.setQueryData(['sessions', true], 'private');
client.setQueryData(['other-sessions', true], 'private');

registerQueryCleanupOnSignOut(clerk, 'sessions');
expect(client.getQueryState(['sessions', true])).toBeUndefined();

registerQueryCleanupOnSignOut(clerk, 'other-sessions');
expect(client.getQueryState(['other-sessions', true])).toBeUndefined();
expect(clerk.addListener).toHaveBeenCalledTimes(1);
});

it('registers one listener across repeated registrations and clears every registered prefix', () => {
const client = __createClerkTestQueryClient();
const { clerk, emit } = createClerkListener();
registerQueryCleanupOnSignOut(clerk, 'sessions');
registerQueryCleanupOnSignOut(clerk, 'sessions');
registerQueryCleanupOnSignOut(clerk, 'other-sessions');
client.setQueryData(['sessions', true], 'private');
client.setQueryData(['other-sessions', true], 'private');

emit(null);

expect(clerk.addListener).toHaveBeenCalledTimes(1);
expect(client.getQueryCache().getAll()).toHaveLength(0);
});

it('moves the subscription to a replacement Clerk while retaining registered prefixes', () => {
const client = __createClerkTestQueryClient();
const old = createClerkListener();
const next = createClerkListener();
registerQueryCleanupOnSignOut(old.clerk, 'sessions');
registerQueryCleanupOnSignOut(next.clerk, 'other-sessions');
client.setQueryData(['sessions', true], 'private');
client.setQueryData(['other-sessions', true], 'private');

old.emit(null);
expect(old.unsubscribe).toHaveBeenCalledTimes(1);
expect(client.getQueryCache().getAll()).toHaveLength(2);

next.emit(null);
expect(client.getQueryCache().getAll()).toHaveLength(0);
});

it.each(['reset', 'replace'] as const)('unsubscribes when the query client is %s', action => {
const original = __createClerkTestQueryClient();
const { clerk, unsubscribe } = createClerkListener();
registerQueryCleanupOnSignOut(clerk, 'sessions');
const callback = clerk.addListener.mock.calls[0]?.[0];
if (action === 'reset') {
__resetClerkQueryClientForTest();
}
const next = __createClerkTestQueryClient();
next.setQueryData(['sessions', true], 'new session');
callback?.(resources(null));

expect(unsubscribe).toHaveBeenCalledTimes(1);
expect(next.getQueryData(['sessions', true])).toBe('new session');
expect(next).not.toBe(original);
});

it('does not subscribe without a real query client', () => {
__setClerkQueryClientForTest(undefined);
const { clerk } = createClerkListener();

registerQueryCleanupOnSignOut(clerk, 'sessions');

expect(clerk.addListener).not.toHaveBeenCalled();
});

it('does not restore a removed entry when an in-flight request resolves', async () => {
const client = __createClerkTestQueryClient();
const { clerk, emit } = createClerkListener();
registerQueryCleanupOnSignOut(clerk, 'sessions');
const deferred = createDeferredPromise();
const pending = client.fetchQuery({ queryKey: ['sessions', true], queryFn: () => deferred.promise });
const settled = pending.catch(() => undefined);

emit(null);
expect(client.getQueryState(['sessions', true])).toBeUndefined();
deferred.resolve('stale sessions');
await settled;
await deferred.promise;

expect(client.getQueryState(['sessions', true])).toBeUndefined();
});
});
51 changes: 51 additions & 0 deletions packages/shared/src/react/query/clerk-query-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { QueryClient } from '@tanstack/query-core';

import type { Clerk } from '../../types';

/**
* The QueryClient backing every clerk-query hook. Owned by `@clerk/shared` so the
* `QueryObserver` that observes it and the `Query` objects inside it always
Expand All @@ -12,6 +14,53 @@ import { QueryClient } from '@tanstack/query-core';
let clerkQueryClient: QueryClient | undefined;
let initialized = false;

type SignOutSubscription = {
clerk: Pick<Clerk, 'addListener' | 'user'>;
stableKeys: Set<string>;
unsubscribe: () => void;
};

let signOutSubscription: SignOutSubscription | undefined;

function disposeSignOutSubscription(): void {
signOutSubscription?.unsubscribe();
signOutSubscription = undefined;
}

function removeAuthenticatedQueries(client: QueryClient, stableKeys: Set<string>): void {
client.removeQueries({
predicate: query => {
const [stableKey, authenticated] = query.queryKey;
return authenticated === true && typeof stableKey === 'string' && stableKeys.has(stableKey);
},
});
}

export function registerQueryCleanupOnSignOut(clerk: SignOutSubscription['clerk'], stableKey: string): void {
const client = getClerkQueryClient();
if (!client) {
return;
}

const stableKeys = signOutSubscription?.stableKeys ?? new Set<string>();
stableKeys.add(stableKey);

if (signOutSubscription?.clerk === clerk) {
if (clerk.user === null) {
removeAuthenticatedQueries(client, stableKeys);
}
return;
}

disposeSignOutSubscription();
const unsubscribe = clerk.addListener(({ user }) => {
if (user === null) {
removeAuthenticatedQueries(client, stableKeys);
}
});
signOutSubscription = { clerk, stableKeys, unsubscribe };
}

export function getClerkQueryClient(): QueryClient | undefined {
if (typeof window === 'undefined') {
return undefined;
Expand All @@ -29,6 +78,7 @@ export function getClerkQueryClient(): QueryClient | undefined {
* state without triggering lazy creation on subsequent reads.
*/
export function __setClerkQueryClientForTest(client: QueryClient | undefined): void {
disposeSignOutSubscription();
clerkQueryClient = client;
initialized = true;
}
Expand Down Expand Up @@ -61,6 +111,7 @@ export function __createClerkTestQueryClient(): QueryClient {
* next read lazy-creates a fresh client.
*/
export function __resetClerkQueryClientForTest(): void {
disposeSignOutSubscription();
clerkQueryClient = undefined;
initialized = false;
}
Loading