From e4861fa9069d75d9a0f506ee33f85eb1c78b50c3 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 30 Oct 2026 13:28:58 -0600 Subject: [PATCH] fix(mosaic,shared): clear cached device sessions after sign-out --- .changeset/quiet-devices-sign-out.md | 6 + ...le-active-devices-section.feature.test.tsx | 15 +- .../src/queries/use-user-sessions-query.ts | 11 +- ...usePersistentQueryCleanupOnSignOut.spec.ts | 44 ++++++ packages/shared/src/react/hooks/index.ts | 5 +- .../react/hooks/useClearQueriesOnSignOut.ts | 13 ++ .../__tests__/clerk-query-client.spec.ts | 145 ++++++++++++++++++ .../src/react/query/clerk-query-client.ts | 51 ++++++ 8 files changed, 279 insertions(+), 11 deletions(-) create mode 100644 .changeset/quiet-devices-sign-out.md create mode 100644 packages/shared/src/react/hooks/__tests__/usePersistentQueryCleanupOnSignOut.spec.ts 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; }