diff --git a/.changeset/quiet-devices-sign-out.md b/.changeset/quiet-devices-sign-out.md
new file mode 100644
index 00000000000..5ca2bb395f6
--- /dev/null
+++ b/.changeset/quiet-devices-sign-out.md
@@ -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.
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/__tests__/user-profile-active-devices-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/__tests__/user-profile-active-devices-section.feature.test.tsx
index da0ef7e1f9c..8c0afe8da1e 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/__tests__/user-profile-active-devices-section.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/__tests__/user-profile-active-devices-section.feature.test.tsx
@@ -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();
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 () => {
diff --git a/packages/mosaic/src/queries/use-user-sessions-query.ts b/packages/mosaic/src/queries/use-user-sessions-query.ts
index d8ce6e20c46..295852d52a9 100644
--- a/packages/mosaic/src/queries/use-user-sessions-query.ts
+++ b/packages/mosaic/src/queries/use-user-sessions-query.ts
@@ -1,7 +1,7 @@
import {
__internal_createCacheKeys,
- __internal_useClearQueriesOnSignOut,
__internal_useClerkQuery,
+ __internal_usePersistentQueryCleanupOnSignOut,
useSession,
useUser,
} from '@clerk/shared/react';
@@ -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,
@@ -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,
diff --git a/packages/shared/src/react/hooks/__tests__/usePersistentQueryCleanupOnSignOut.spec.ts b/packages/shared/src/react/hooks/__tests__/usePersistentQueryCleanupOnSignOut.spec.ts
new file mode 100644
index 00000000000..20232052a61
--- /dev/null
+++ b/packages/shared/src/react/hooks/__tests__/usePersistentQueryCleanupOnSignOut.spec.ts
@@ -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>(),
+}));
+
+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(() => 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();
+});
diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts
index 700275614d8..26b85f3027c 100644
--- a/packages/shared/src/react/hooks/index.ts
+++ b/packages/shared/src/react/hooks/index.ts
@@ -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';
diff --git a/packages/shared/src/react/hooks/useClearQueriesOnSignOut.ts b/packages/shared/src/react/hooks/useClearQueriesOnSignOut.ts
index 25ae0847e34..e9b969c7f2d 100644
--- a/packages/shared/src/react/hooks/useClearQueriesOnSignOut.ts
+++ b/packages/shared/src/react/hooks/useClearQueriesOnSignOut.ts
@@ -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';
@@ -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]);
+}
diff --git a/packages/shared/src/react/query/__tests__/clerk-query-client.spec.ts b/packages/shared/src/react/query/__tests__/clerk-query-client.spec.ts
index 0d3e8eba0ad..8a32899445f 100644
--- a/packages/shared/src/react/query/__tests__/clerk-query-client.spec.ts
+++ b/packages/shared/src/react/query/__tests__/clerk-query-client.spec.ts
@@ -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(() => {
@@ -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[0]>();
+ const unsubscribe = vi.fn();
+ const clerk = {
+ user,
+ addListener: vi.fn(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();
+ });
+});
diff --git a/packages/shared/src/react/query/clerk-query-client.ts b/packages/shared/src/react/query/clerk-query-client.ts
index bb155d005f4..fd5b6e05211 100644
--- a/packages/shared/src/react/query/clerk-query-client.ts
+++ b/packages/shared/src/react/query/clerk-query-client.ts
@@ -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
@@ -12,6 +14,53 @@ import { QueryClient } from '@tanstack/query-core';
let clerkQueryClient: QueryClient | undefined;
let initialized = false;
+type SignOutSubscription = {
+ clerk: Pick;
+ stableKeys: Set;
+ unsubscribe: () => void;
+};
+
+let signOutSubscription: SignOutSubscription | undefined;
+
+function disposeSignOutSubscription(): void {
+ signOutSubscription?.unsubscribe();
+ signOutSubscription = undefined;
+}
+
+function removeAuthenticatedQueries(client: QueryClient, stableKeys: Set): 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();
+ 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;
@@ -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;
}
@@ -61,6 +111,7 @@ export function __createClerkTestQueryClient(): QueryClient {
* next read lazy-creates a fresh client.
*/
export function __resetClerkQueryClientForTest(): void {
+ disposeSignOutSubscription();
clerkQueryClient = undefined;
initialized = false;
}