Skip to content

Commit e92edf7

Browse files
fix(mosaic,shared): clear cached device sessions after sign-out
1 parent 0905976 commit e92edf7

8 files changed

Lines changed: 279 additions & 11 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/shared': patch
3+
'@clerk/mosaic': patch
4+
---
5+
6+
Fix cached active device sessions remaining after sign-out when the UserProfile device section has already been closed.

‎packages/mosaic/src/features/user-profile/user-profile-active-devices-section/__tests__/user-profile-active-devices-section.feature.test.tsx‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,21 +217,32 @@ describe('Active devices', () => {
217217
}
218218
});
219219

220-
it('drops cached devices on sign out', async () => {
220+
it.each(['mounted', 'unmounted'] as const)('drops cached devices on sign out when %s', async state => {
221221
serveDevices([device('sess_current', 'active')]);
222222
const view = await renderWithClerk(<UserProfileActiveDevicesSection />);
223223
expect(await screen.findByText('This device')).toBeVisible();
224+
const queryClient = getClerkQueryClient();
224225
const cachedDevices = () =>
225-
getClerkQueryClient()
226+
queryClient
226227
?.getQueryCache()
227228
.getAll()
228229
.filter(query => query.state.data !== undefined) ?? [];
229230
expect(cachedDevices()).toHaveLength(1);
231+
const cachedKeys = cachedDevices().map(query => query.queryKey);
232+
233+
if (state === 'unmounted') {
234+
view.unmount();
235+
expect(cachedDevices()).toHaveLength(1);
236+
}
230237

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

233240
expect(view.container).toBeEmptyDOMElement();
241+
expect(getClerkQueryClient()).toBe(queryClient);
234242
expect(cachedDevices()).toHaveLength(0);
243+
for (const queryKey of cachedKeys) {
244+
expect(queryClient?.getQueryCache().find({ queryKey, exact: true })).toBeUndefined();
245+
}
235246
});
236247

237248
it('shows signed-in sessions and signs out another device inside the security panel', async () => {

‎packages/mosaic/src/queries/use-user-sessions-query.ts‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {
22
__internal_createCacheKeys,
3-
__internal_useClearQueriesOnSignOut,
43
__internal_useClerkQuery,
4+
__internal_usePersistentQueryCleanupOnSignOut,
55
useSession,
66
useUser,
77
} from '@clerk/shared/react';
@@ -15,7 +15,7 @@ export function useUserSessionsQuery() {
1515
const userId = user?.id;
1616
const sessionId = session?.id;
1717

18-
const { queryKey, stableKey, authenticated } = useMemo(
18+
const { queryKey, stableKey } = useMemo(
1919
() =>
2020
__internal_createCacheKeys({
2121
stablePrefix: USER_SESSIONS_KEY,
@@ -26,12 +26,7 @@ export function useUserSessionsQuery() {
2626
[userId, sessionId],
2727
);
2828

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

3631
return __internal_useClerkQuery({
3732
queryKey,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { renderHook } from '@testing-library/react';
2+
import { StrictMode } from 'react';
3+
import { afterEach, expect, it, vi } from 'vitest';
4+
5+
import type { Clerk, Resources } from '@/types';
6+
7+
import { __createClerkTestQueryClient, __resetClerkQueryClientForTest } from '../../query/clerk-query-client';
8+
import { usePersistentQueryCleanupOnSignOut } from '../useClearQueriesOnSignOut';
9+
10+
const { useClerkInstanceContext } = vi.hoisted(() => ({
11+
useClerkInstanceContext: vi.fn<() => Pick<Clerk, 'addListener' | 'user'>>(),
12+
}));
13+
14+
vi.mock('../../contexts', () => ({ useClerkInstanceContext }));
15+
16+
afterEach(() => {
17+
__resetClerkQueryClientForTest();
18+
vi.clearAllMocks();
19+
});
20+
21+
it('keeps one sign-out subscription after StrictMode setup and unmount', () => {
22+
const client = __createClerkTestQueryClient();
23+
const unsubscribe = vi.fn();
24+
const clerk = {
25+
user: undefined,
26+
addListener: vi.fn<Clerk['addListener']>(() => unsubscribe),
27+
};
28+
useClerkInstanceContext.mockReturnValue(clerk);
29+
client.setQueryData(['sessions', true], 'private');
30+
31+
const { unmount } = renderHook(() => usePersistentQueryCleanupOnSignOut('sessions'), { wrapper: StrictMode });
32+
unmount();
33+
34+
expect(clerk.addListener).toHaveBeenCalledTimes(1);
35+
expect(unsubscribe).not.toHaveBeenCalled();
36+
const callback = clerk.addListener.mock.calls[0]?.[0];
37+
callback?.({
38+
user: null,
39+
get client(): Resources['client'] {
40+
throw new Error('Sign-out cleanup must not read the client resource');
41+
},
42+
});
43+
expect(client.getQueryState(['sessions', true])).toBeUndefined();
44+
});

‎packages/shared/src/react/hooks/index.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,10 @@ export type {
8383
} from './useOrganizationSSOBypassAllowlist';
8484

8585
export { createCacheKeys as __internal_createCacheKeys } from './createCacheKeys';
86-
export { useClearQueriesOnSignOut as __internal_useClearQueriesOnSignOut } from './useClearQueriesOnSignOut';
86+
export {
87+
useClearQueriesOnSignOut as __internal_useClearQueriesOnSignOut,
88+
usePersistentQueryCleanupOnSignOut as __internal_usePersistentQueryCleanupOnSignOut,
89+
} from './useClearQueriesOnSignOut';
8790
export { useClerkQuery as __internal_useClerkQuery } from '../query/useQuery';
8891

8992
export { useUserBase as __internal_useUserBase } from './base/useUserBase';

‎packages/shared/src/react/hooks/useClearQueriesOnSignOut.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useEffect, useRef } from 'react';
22

3+
import { useClerkInstanceContext } from '../contexts';
4+
import { registerQueryCleanupOnSignOut } from '../query/clerk-query-client';
35
import { useClerkQueryClient } from '../query/use-clerk-query-client';
46
import { usePreviousValue } from './usePreviousValue';
57

@@ -59,3 +61,14 @@ export function useClearQueriesOnSignOut(options: ClearQueriesOnSignOutOptions)
5961
}
6062
}, [authenticated, isSignedOut, previousIsSignedIn, queryClient]);
6163
}
64+
65+
export function usePersistentQueryCleanupOnSignOut(stableKey: string): void {
66+
const clerk = useClerkInstanceContext();
67+
const [queryClient, isLoaded] = useClerkQueryClient();
68+
69+
useEffect(() => {
70+
if (isLoaded) {
71+
registerQueryCleanupOnSignOut(clerk, stableKey);
72+
}
73+
}, [clerk, isLoaded, queryClient, stableKey]);
74+
}

‎packages/shared/src/react/query/__tests__/clerk-query-client.spec.ts‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { QueryClient } from '@tanstack/query-core';
22
import { afterEach, describe, expect, it, vi } from 'vitest';
33

4+
import type { Clerk, Resources } from '@/types';
5+
import { createDeferredPromise } from '@/utils/createDeferredPromise';
6+
47
import {
58
__createClerkTestQueryClient,
69
__resetClerkQueryClientForTest,
710
__setClerkQueryClientForTest,
811
getClerkQueryClient,
12+
registerQueryCleanupOnSignOut,
913
} from '../clerk-query-client';
1014

1115
afterEach(() => {
@@ -82,3 +86,144 @@ describe('__createClerkTestQueryClient', () => {
8286
expect(defaults?.refetchOnMount).toBe(false);
8387
});
8488
});
89+
90+
function resources(user: Clerk['user']): Resources {
91+
return {
92+
user,
93+
get client(): Resources['client'] {
94+
throw new Error('Sign-out cleanup must not read the client resource');
95+
},
96+
};
97+
}
98+
99+
function createClerkListener(user: Clerk['user'] = undefined) {
100+
const listeners = new Set<Parameters<Clerk['addListener']>[0]>();
101+
const unsubscribe = vi.fn();
102+
const clerk = {
103+
user,
104+
addListener: vi.fn<Clerk['addListener']>(callback => {
105+
listeners.add(callback);
106+
callback(resources(clerk.user));
107+
return () => {
108+
listeners.delete(callback);
109+
unsubscribe();
110+
};
111+
}),
112+
};
113+
return {
114+
clerk,
115+
unsubscribe,
116+
emit(user: Clerk['user']) {
117+
clerk.user = user;
118+
listeners.forEach(callback => callback(resources(user)));
119+
},
120+
};
121+
}
122+
123+
describe('registerQueryCleanupOnSignOut', () => {
124+
it('clears registered authenticated queries on null but ignores unresolved user state', () => {
125+
const client = __createClerkTestQueryClient();
126+
const { clerk, emit } = createClerkListener();
127+
client.setQueryData(['sessions', true], 'private');
128+
client.setQueryData(['sessions', false], 'public');
129+
client.setQueryData(['billing', true], 'unrelated');
130+
131+
registerQueryCleanupOnSignOut(clerk, 'sessions');
132+
emit(undefined);
133+
expect(client.getQueryData(['sessions', true])).toBe('private');
134+
135+
emit(null);
136+
expect(client.getQueryState(['sessions', true])).toBeUndefined();
137+
expect(client.getQueryData(['sessions', false])).toBe('public');
138+
expect(client.getQueryData(['billing', true])).toBe('unrelated');
139+
});
140+
141+
it('clears stale entries on initial null and when another prefix registers after sign-out', () => {
142+
const client = __createClerkTestQueryClient();
143+
const { clerk } = createClerkListener(null);
144+
client.setQueryData(['sessions', true], 'private');
145+
client.setQueryData(['other-sessions', true], 'private');
146+
147+
registerQueryCleanupOnSignOut(clerk, 'sessions');
148+
expect(client.getQueryState(['sessions', true])).toBeUndefined();
149+
150+
registerQueryCleanupOnSignOut(clerk, 'other-sessions');
151+
expect(client.getQueryState(['other-sessions', true])).toBeUndefined();
152+
expect(clerk.addListener).toHaveBeenCalledTimes(1);
153+
});
154+
155+
it('registers one listener across repeated registrations and clears every registered prefix', () => {
156+
const client = __createClerkTestQueryClient();
157+
const { clerk, emit } = createClerkListener();
158+
registerQueryCleanupOnSignOut(clerk, 'sessions');
159+
registerQueryCleanupOnSignOut(clerk, 'sessions');
160+
registerQueryCleanupOnSignOut(clerk, 'other-sessions');
161+
client.setQueryData(['sessions', true], 'private');
162+
client.setQueryData(['other-sessions', true], 'private');
163+
164+
emit(null);
165+
166+
expect(clerk.addListener).toHaveBeenCalledTimes(1);
167+
expect(client.getQueryCache().getAll()).toHaveLength(0);
168+
});
169+
170+
it('moves the subscription to a replacement Clerk while retaining registered prefixes', () => {
171+
const client = __createClerkTestQueryClient();
172+
const old = createClerkListener();
173+
const next = createClerkListener();
174+
registerQueryCleanupOnSignOut(old.clerk, 'sessions');
175+
registerQueryCleanupOnSignOut(next.clerk, 'other-sessions');
176+
client.setQueryData(['sessions', true], 'private');
177+
client.setQueryData(['other-sessions', true], 'private');
178+
179+
old.emit(null);
180+
expect(old.unsubscribe).toHaveBeenCalledTimes(1);
181+
expect(client.getQueryCache().getAll()).toHaveLength(2);
182+
183+
next.emit(null);
184+
expect(client.getQueryCache().getAll()).toHaveLength(0);
185+
});
186+
187+
it.each(['reset', 'replace'] as const)('unsubscribes when the query client is %s', action => {
188+
const original = __createClerkTestQueryClient();
189+
const { clerk, unsubscribe } = createClerkListener();
190+
registerQueryCleanupOnSignOut(clerk, 'sessions');
191+
const callback = clerk.addListener.mock.calls[0]?.[0];
192+
if (action === 'reset') {
193+
__resetClerkQueryClientForTest();
194+
}
195+
const next = __createClerkTestQueryClient();
196+
next.setQueryData(['sessions', true], 'new session');
197+
callback?.(resources(null));
198+
199+
expect(unsubscribe).toHaveBeenCalledTimes(1);
200+
expect(next.getQueryData(['sessions', true])).toBe('new session');
201+
expect(next).not.toBe(original);
202+
});
203+
204+
it('does not subscribe without a real query client', () => {
205+
__setClerkQueryClientForTest(undefined);
206+
const { clerk } = createClerkListener();
207+
208+
registerQueryCleanupOnSignOut(clerk, 'sessions');
209+
210+
expect(clerk.addListener).not.toHaveBeenCalled();
211+
});
212+
213+
it('does not restore a removed entry when an in-flight request resolves', async () => {
214+
const client = __createClerkTestQueryClient();
215+
const { clerk, emit } = createClerkListener();
216+
registerQueryCleanupOnSignOut(clerk, 'sessions');
217+
const deferred = createDeferredPromise();
218+
const pending = client.fetchQuery({ queryKey: ['sessions', true], queryFn: () => deferred.promise });
219+
const settled = pending.catch(() => undefined);
220+
221+
emit(null);
222+
expect(client.getQueryState(['sessions', true])).toBeUndefined();
223+
deferred.resolve('stale sessions');
224+
await settled;
225+
await deferred.promise;
226+
227+
expect(client.getQueryState(['sessions', true])).toBeUndefined();
228+
});
229+
});

‎packages/shared/src/react/query/clerk-query-client.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { QueryClient } from '@tanstack/query-core';
22

3+
import type { Clerk } from '../../types';
4+
35
/**
46
* The QueryClient backing every clerk-query hook. Owned by `@clerk/shared` so the
57
* `QueryObserver` that observes it and the `Query` objects inside it always
@@ -12,6 +14,53 @@ import { QueryClient } from '@tanstack/query-core';
1214
let clerkQueryClient: QueryClient | undefined;
1315
let initialized = false;
1416

17+
type SignOutSubscription = {
18+
clerk: Pick<Clerk, 'addListener' | 'user'>;
19+
stableKeys: Set<string>;
20+
unsubscribe: () => void;
21+
};
22+
23+
let signOutSubscription: SignOutSubscription | undefined;
24+
25+
function disposeSignOutSubscription(): void {
26+
signOutSubscription?.unsubscribe();
27+
signOutSubscription = undefined;
28+
}
29+
30+
function removeAuthenticatedQueries(client: QueryClient, stableKeys: Set<string>): void {
31+
client.removeQueries({
32+
predicate: query => {
33+
const [stableKey, authenticated] = query.queryKey;
34+
return authenticated === true && typeof stableKey === 'string' && stableKeys.has(stableKey);
35+
},
36+
});
37+
}
38+
39+
export function registerQueryCleanupOnSignOut(clerk: SignOutSubscription['clerk'], stableKey: string): void {
40+
const client = getClerkQueryClient();
41+
if (!client) {
42+
return;
43+
}
44+
45+
const stableKeys = signOutSubscription?.stableKeys ?? new Set<string>();
46+
stableKeys.add(stableKey);
47+
48+
if (signOutSubscription?.clerk === clerk) {
49+
if (clerk.user === null) {
50+
removeAuthenticatedQueries(client, stableKeys);
51+
}
52+
return;
53+
}
54+
55+
disposeSignOutSubscription();
56+
const unsubscribe = clerk.addListener(({ user }) => {
57+
if (user === null) {
58+
removeAuthenticatedQueries(client, stableKeys);
59+
}
60+
});
61+
signOutSubscription = { clerk, stableKeys, unsubscribe };
62+
}
63+
1564
export function getClerkQueryClient(): QueryClient | undefined {
1665
if (typeof window === 'undefined') {
1766
return undefined;
@@ -29,6 +78,7 @@ export function getClerkQueryClient(): QueryClient | undefined {
2978
* state without triggering lazy creation on subsequent reads.
3079
*/
3180
export function __setClerkQueryClientForTest(client: QueryClient | undefined): void {
81+
disposeSignOutSubscription();
3282
clerkQueryClient = client;
3383
initialized = true;
3484
}
@@ -61,6 +111,7 @@ export function __createClerkTestQueryClient(): QueryClient {
61111
* next read lazy-creates a fresh client.
62112
*/
63113
export function __resetClerkQueryClientForTest(): void {
114+
disposeSignOutSubscription();
64115
clerkQueryClient = undefined;
65116
initialized = false;
66117
}

0 commit comments

Comments
 (0)