Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/polite-cars-lose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': patch
---

Fix `useClearQueriesOnSignOut` hook by removing conditional `useEffect`
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { useClearQueriesOnSignOut, withInfiniteKey } from '../useClearQueriesOnSignOut';
import { createMockQueryClient } from './mocks/clerk';

const mockQueryClient = createMockQueryClient();

vi.mock('../../clerk-rq/use-clerk-query-client', () => ({
useClerkQueryClient: () => [mockQueryClient.client],
}));

beforeEach(() => {
vi.clearAllMocks();
mockQueryClient.client.clear();
});

describe('useClearQueriesOnSignOut', () => {
describe('withInfiniteKey helper', () => {
it('returns array with regular and infinite key variants', () => {
const result = withInfiniteKey('test-key');
expect(result).toEqual(['test-key', 'test-key-inf']);
});
});

describe('hook order stability', () => {
it('should not throw when authenticated value changes', () => {
// This test verifies the fix for the conditional useEffect issue.
// Previously, changing `authenticated` would cause hook order errors.
const { rerender } = renderHook(
({ authenticated, isSignedOut }: { authenticated: boolean; isSignedOut: boolean }) =>
useClearQueriesOnSignOut({
isSignedOut,
stableKeys: 'test-key',
authenticated,
}),
{ initialProps: { authenticated: false, isSignedOut: false } },
);

// Should not throw when authenticated changes
expect(() => {
rerender({ authenticated: true, isSignedOut: false });
}).not.toThrow();

expect(() => {
rerender({ authenticated: false, isSignedOut: true });
}).not.toThrow();
});
});

describe('sign-out query clearing', () => {
it('should clear queries when transitioning from signed-in to signed-out', () => {
// Setup: Add a query to the cache
mockQueryClient.client.setQueryData(['test-key', true, {}, {}], { data: 'cached' });

const { rerender } = renderHook(
({ isSignedOut }: { isSignedOut: boolean }) =>
useClearQueriesOnSignOut({
isSignedOut,
stableKeys: 'test-key',
authenticated: true,
}),
{ initialProps: { isSignedOut: false } },
);

// Verify query exists
expect(mockQueryClient.client.getQueryData(['test-key', true, {}, {}])).toBeDefined();

// Transition to signed-out
act(() => {
rerender({ isSignedOut: true });
});

// Query should be cleared
expect(mockQueryClient.client.getQueryData(['test-key', true, {}, {}])).toBeUndefined();
});

it('should NOT clear queries during initial load (first render)', () => {
// Setup: Add a query to the cache before mounting
mockQueryClient.client.setQueryData(['test-key', true, {}, {}], { data: 'cached' });

// Mount with isSignedOut=true (simulating initial load with undefined user)
renderHook(() =>
useClearQueriesOnSignOut({
isSignedOut: true,
stableKeys: 'test-key',
authenticated: true,
}),
);

// Query should NOT be cleared on first render
// because previousIsSignedIn is null on first render
expect(mockQueryClient.client.getQueryData(['test-key', true, {}, {}])).toBeDefined();
});

it('should NOT clear queries when isSignedOut stays false', () => {
mockQueryClient.client.setQueryData(['test-key', true, {}, {}], { data: 'cached' });

const { rerender } = renderHook(
({ isSignedOut }: { isSignedOut: boolean }) =>
useClearQueriesOnSignOut({
isSignedOut,
stableKeys: 'test-key',
authenticated: true,
}),
{ initialProps: { isSignedOut: false } },
);

// Re-render with same value
act(() => {
rerender({ isSignedOut: false });
});

// Query should still exist
expect(mockQueryClient.client.getQueryData(['test-key', true, {}, {}])).toBeDefined();
});
});

describe('authenticated parameter behavior', () => {
it('should skip cleanup when authenticated is false', () => {
mockQueryClient.client.setQueryData(['test-key', true, {}, {}], { data: 'cached' });

const { rerender } = renderHook(
({ isSignedOut }: { isSignedOut: boolean }) =>
useClearQueriesOnSignOut({
isSignedOut,
stableKeys: 'test-key',
authenticated: false,
}),
{ initialProps: { isSignedOut: false } },
);

// Transition to signed-out
act(() => {
rerender({ isSignedOut: true });
});

// Query should NOT be cleared because authenticated is false
expect(mockQueryClient.client.getQueryData(['test-key', true, {}, {}])).toBeDefined();
});

it('should only clear queries with matching stableKey', () => {
// Setup: Add multiple queries
mockQueryClient.client.setQueryData(['key-a', true, {}, {}], { data: 'a' });
mockQueryClient.client.setQueryData(['key-b', true, {}, {}], { data: 'b' });
mockQueryClient.client.setQueryData(['key-c', true, {}, {}], { data: 'c' });

const { rerender } = renderHook(
({ isSignedOut }: { isSignedOut: boolean }) =>
useClearQueriesOnSignOut({
isSignedOut,
stableKeys: 'key-a',
authenticated: true,
}),
{ initialProps: { isSignedOut: false } },
);

act(() => {
rerender({ isSignedOut: true });
});

// Only key-a should be cleared
expect(mockQueryClient.client.getQueryData(['key-a', true, {}, {}])).toBeUndefined();
expect(mockQueryClient.client.getQueryData(['key-b', true, {}, {}])).toBeDefined();
expect(mockQueryClient.client.getQueryData(['key-c', true, {}, {}])).toBeDefined();
});

it('should clear multiple queries when stableKeys is an array', () => {
mockQueryClient.client.setQueryData(['key-a', true, {}, {}], { data: 'a' });
mockQueryClient.client.setQueryData(['key-b', true, {}, {}], { data: 'b' });
mockQueryClient.client.setQueryData(['key-c', true, {}, {}], { data: 'c' });

const { rerender } = renderHook(
({ isSignedOut }: { isSignedOut: boolean }) =>
useClearQueriesOnSignOut({
isSignedOut,
stableKeys: ['key-a', 'key-b'],
authenticated: true,
}),
{ initialProps: { isSignedOut: false } },
);

act(() => {
rerender({ isSignedOut: true });
});

// key-a and key-b should be cleared
expect(mockQueryClient.client.getQueryData(['key-a', true, {}, {}])).toBeUndefined();
expect(mockQueryClient.client.getQueryData(['key-b', true, {}, {}])).toBeUndefined();
// key-c should remain
expect(mockQueryClient.client.getQueryData(['key-c', true, {}, {}])).toBeDefined();
});

it('should only clear queries marked as authenticated in cache key', () => {
// Setup: Add both authenticated and unauthenticated queries
mockQueryClient.client.setQueryData(['test-key', true, {}, {}], { data: 'authenticated' });
mockQueryClient.client.setQueryData(['test-key', false, {}, {}], { data: 'unauthenticated' });

const { rerender } = renderHook(
({ isSignedOut }: { isSignedOut: boolean }) =>
useClearQueriesOnSignOut({
isSignedOut,
stableKeys: 'test-key',
authenticated: true,
}),
{ initialProps: { isSignedOut: false } },
);

act(() => {
rerender({ isSignedOut: true });
});

// Only authenticated query should be cleared
expect(mockQueryClient.client.getQueryData(['test-key', true, {}, {}])).toBeUndefined();
expect(mockQueryClient.client.getQueryData(['test-key', false, {}, {}])).toBeDefined();
});
});

describe('onCleanup callback', () => {
it('should call onCleanup after clearing queries', () => {
const onCleanup = vi.fn();
mockQueryClient.client.setQueryData(['test-key', true, {}, {}], { data: 'cached' });

const { rerender } = renderHook(
({ isSignedOut }: { isSignedOut: boolean }) =>
useClearQueriesOnSignOut({
isSignedOut,
stableKeys: 'test-key',
authenticated: true,
onCleanup,
}),
{ initialProps: { isSignedOut: false } },
);

expect(onCleanup).not.toHaveBeenCalled();

act(() => {
rerender({ isSignedOut: true });
});

expect(onCleanup).toHaveBeenCalledTimes(1);
});

it('should NOT call onCleanup when authenticated is false', () => {
const onCleanup = vi.fn();

const { rerender } = renderHook(
({ isSignedOut }: { isSignedOut: boolean }) =>
useClearQueriesOnSignOut({
isSignedOut,
stableKeys: 'test-key',
authenticated: false,
onCleanup,
}),
{ initialProps: { isSignedOut: false } },
);

act(() => {
rerender({ isSignedOut: true });
});

expect(onCleanup).not.toHaveBeenCalled();
});

it('should NOT call onCleanup on initial render even if isSignedOut is true', () => {
const onCleanup = vi.fn();

renderHook(() =>
useClearQueriesOnSignOut({
isSignedOut: true,
stableKeys: 'test-key',
authenticated: true,
onCleanup,
}),
);

expect(onCleanup).not.toHaveBeenCalled();
});
});

describe('state transitions', () => {
it('should handle rapid sign-in/sign-out transitions correctly', () => {
const onCleanup = vi.fn();
mockQueryClient.client.setQueryData(['test-key', true, {}, {}], { data: 'cached' });

const { rerender } = renderHook(
({ isSignedOut }: { isSignedOut: boolean }) =>
useClearQueriesOnSignOut({
isSignedOut,
stableKeys: 'test-key',
authenticated: true,
onCleanup,
}),
{ initialProps: { isSignedOut: false } },
);

// Sign out
act(() => {
rerender({ isSignedOut: true });
});
expect(onCleanup).toHaveBeenCalledTimes(1);

// Re-add data and sign in
mockQueryClient.client.setQueryData(['test-key', true, {}, {}], { data: 'new-cached' });
act(() => {
rerender({ isSignedOut: false });
});
expect(onCleanup).toHaveBeenCalledTimes(1); // Still 1, no additional call

// Sign out again
act(() => {
rerender({ isSignedOut: true });
});
expect(onCleanup).toHaveBeenCalledTimes(2);
});
});
});
4 changes: 2 additions & 2 deletions packages/shared/src/react/hooks/useAPIKeys.rq.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,13 @@ export function useAPIKeys<T extends UseAPIKeysParams>(params?: T): UseAPIKeysRe
keepPreviousData: safeValues.keepPreviousData,
infinite: safeValues.infinite,
enabled: isEnabled,
isSignedIn: Boolean(clerk.user),
isSignedIn: clerk.user !== null,
initialPage: safeValues.initialPage,
pageSize: safeValues.pageSize,
},
keys: createCacheKeys({
stablePrefix: STABLE_KEYS.API_KEYS_KEY,
authenticated: Boolean(clerk.user),
authenticated: true,
tracked: {
subject: safeValues.subject,
},
Expand Down
4 changes: 2 additions & 2 deletions packages/shared/src/react/hooks/useAPIKeys.swr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,13 @@ export function useAPIKeys<T extends UseAPIKeysParams>(params?: T): UseAPIKeysRe
keepPreviousData: safeValues.keepPreviousData,
infinite: safeValues.infinite,
enabled: isEnabled,
isSignedIn: Boolean(clerk.user),
isSignedIn: clerk.user !== null,
initialPage: safeValues.initialPage,
pageSize: safeValues.pageSize,
},
keys: createCacheKeys({
stablePrefix: STABLE_KEYS.API_KEYS_KEY,
authenticated: Boolean(clerk.user),
authenticated: true,
tracked: {
subject: safeValues.subject,
},
Expand Down
15 changes: 6 additions & 9 deletions packages/shared/src/react/hooks/useClearQueriesOnSignOut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,12 @@ export function useClearQueriesOnSignOut(options: ClearQueriesOnSignOutOptions)
const [queryClient] = useClerkQueryClient();
const previousIsSignedIn = usePreviousValue(!isSignedOut);

// If this hook's cache keys are not authenticated, skip all cleanup logic.

if (authenticated !== true) {
return;
}

// Calling this effect conditionally because we make sure that `authenticated` is always the same throughout the component lifecycle.
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
// If this hook's cache keys are not authenticated, skip all cleanup logic.
if (authenticated !== true) {
return;
}

const isNowSignedOut = isSignedOut === true;

if (previousIsSignedIn && isNowSignedOut) {
Expand All @@ -60,5 +57,5 @@ export function useClearQueriesOnSignOut(options: ClearQueriesOnSignOutOptions)

onCleanup?.();
}
}, [isSignedOut, previousIsSignedIn, queryClient]);
}, [authenticated, isSignedOut, previousIsSignedIn, queryClient]);
}
Loading
Loading