diff --git a/.changeset/polite-cars-lose.md b/.changeset/polite-cars-lose.md new file mode 100644 index 00000000000..0f970f12963 --- /dev/null +++ b/.changeset/polite-cars-lose.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Fix `useClearQueriesOnSignOut` hook by removing conditional `useEffect` diff --git a/packages/shared/src/react/hooks/__tests__/useClearQueriesOnSignOut.spec.ts b/packages/shared/src/react/hooks/__tests__/useClearQueriesOnSignOut.spec.ts new file mode 100644 index 00000000000..046d6e9c51a --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/useClearQueriesOnSignOut.spec.ts @@ -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); + }); + }); +}); diff --git a/packages/shared/src/react/hooks/useAPIKeys.rq.tsx b/packages/shared/src/react/hooks/useAPIKeys.rq.tsx index 7423a4bc59b..cff886d12c8 100644 --- a/packages/shared/src/react/hooks/useAPIKeys.rq.tsx +++ b/packages/shared/src/react/hooks/useAPIKeys.rq.tsx @@ -102,13 +102,13 @@ export function useAPIKeys(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, }, diff --git a/packages/shared/src/react/hooks/useAPIKeys.swr.tsx b/packages/shared/src/react/hooks/useAPIKeys.swr.tsx index 7780f8e85a1..cf11c2a7685 100644 --- a/packages/shared/src/react/hooks/useAPIKeys.swr.tsx +++ b/packages/shared/src/react/hooks/useAPIKeys.swr.tsx @@ -105,13 +105,13 @@ export function useAPIKeys(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, }, diff --git a/packages/shared/src/react/hooks/useClearQueriesOnSignOut.ts b/packages/shared/src/react/hooks/useClearQueriesOnSignOut.ts index f0a2491f583..ddf95198123 100644 --- a/packages/shared/src/react/hooks/useClearQueriesOnSignOut.ts +++ b/packages/shared/src/react/hooks/useClearQueriesOnSignOut.ts @@ -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) { @@ -60,5 +57,5 @@ export function useClearQueriesOnSignOut(options: ClearQueriesOnSignOutOptions) onCleanup?.(); } - }, [isSignedOut, previousIsSignedIn, queryClient]); + }, [authenticated, isSignedOut, previousIsSignedIn, queryClient]); } diff --git a/packages/shared/src/react/hooks/useOrganization.tsx b/packages/shared/src/react/hooks/useOrganization.tsx index a942a9cf62b..c23bb3be377 100644 --- a/packages/shared/src/react/hooks/useOrganization.tsx +++ b/packages/shared/src/react/hooks/useOrganization.tsx @@ -366,13 +366,13 @@ export function useOrganization(params?: T): Us keepPreviousData: domainSafeValues.keepPreviousData, infinite: domainSafeValues.infinite, enabled: !!domainParams, - isSignedIn: Boolean(organization), + isSignedIn: organization !== null, initialPage: domainSafeValues.initialPage, pageSize: domainSafeValues.pageSize, }, keys: createCacheKeys({ stablePrefix: STABLE_KEYS.DOMAINS_KEY, - authenticated: Boolean(organization), + authenticated: true, tracked: { organizationId: organization?.id, }, @@ -388,13 +388,13 @@ export function useOrganization(params?: T): Us keepPreviousData: membershipRequestSafeValues.keepPreviousData, infinite: membershipRequestSafeValues.infinite, enabled: !!membershipRequestParams, - isSignedIn: Boolean(organization), + isSignedIn: organization !== null, initialPage: membershipRequestSafeValues.initialPage, pageSize: membershipRequestSafeValues.pageSize, }, keys: createCacheKeys({ stablePrefix: STABLE_KEYS.MEMBERSHIP_REQUESTS_KEY, - authenticated: Boolean(organization), + authenticated: true, tracked: { organizationId: organization?.id, }, @@ -410,13 +410,13 @@ export function useOrganization(params?: T): Us keepPreviousData: membersSafeValues.keepPreviousData, infinite: membersSafeValues.infinite, enabled: !!membersParams, - isSignedIn: Boolean(organization), + isSignedIn: organization !== null, initialPage: membersSafeValues.initialPage, pageSize: membersSafeValues.pageSize, }, keys: createCacheKeys({ stablePrefix: STABLE_KEYS.MEMBERSHIPS_KEY, - authenticated: Boolean(organization), + authenticated: true, tracked: { organizationId: organization?.id, }, @@ -432,13 +432,13 @@ export function useOrganization(params?: T): Us keepPreviousData: invitationsSafeValues.keepPreviousData, infinite: invitationsSafeValues.infinite, enabled: !!invitationsParams, - isSignedIn: Boolean(organization), + isSignedIn: organization !== null, initialPage: invitationsSafeValues.initialPage, pageSize: invitationsSafeValues.pageSize, }, keys: createCacheKeys({ stablePrefix: STABLE_KEYS.INVITATIONS_KEY, - authenticated: Boolean(organization), + authenticated: true, tracked: { organizationId: organization?.id, }, diff --git a/packages/shared/src/react/hooks/useOrganizationList.tsx b/packages/shared/src/react/hooks/useOrganizationList.tsx index c43b47ee6d1..8060e48a22f 100644 --- a/packages/shared/src/react/hooks/useOrganizationList.tsx +++ b/packages/shared/src/react/hooks/useOrganizationList.tsx @@ -316,13 +316,13 @@ export function useOrganizationList(params? keepPreviousData: userMembershipsSafeValues.keepPreviousData, infinite: userMembershipsSafeValues.infinite, enabled: !!userMembershipsParams, - isSignedIn: Boolean(user), + isSignedIn: user !== null, initialPage: userMembershipsSafeValues.initialPage, pageSize: userMembershipsSafeValues.pageSize, }, keys: createCacheKeys({ stablePrefix: STABLE_KEYS.USER_MEMBERSHIPS_KEY, - authenticated: Boolean(user), + authenticated: true, tracked: { userId: user?.id, }, @@ -339,13 +339,13 @@ export function useOrganizationList(params? infinite: userInvitationsSafeValues.infinite, // In useOrganizationList, you need to opt in by passing an object or `true`. enabled: !!userInvitationsParams, - isSignedIn: Boolean(user), + isSignedIn: user !== null, initialPage: userInvitationsSafeValues.initialPage, pageSize: userInvitationsSafeValues.pageSize, }, keys: createCacheKeys({ stablePrefix: STABLE_KEYS.USER_INVITATIONS_KEY, - authenticated: Boolean(user), + authenticated: true, tracked: { userId: user?.id, }, @@ -361,13 +361,13 @@ export function useOrganizationList(params? keepPreviousData: userSuggestionsSafeValues.keepPreviousData, infinite: userSuggestionsSafeValues.infinite, enabled: !!userSuggestionsParams, - isSignedIn: Boolean(user), + isSignedIn: user !== null, initialPage: userSuggestionsSafeValues.initialPage, pageSize: userSuggestionsSafeValues.pageSize, }, keys: createCacheKeys({ stablePrefix: STABLE_KEYS.USER_SUGGESTIONS_KEY, - authenticated: Boolean(user), + authenticated: true, tracked: { userId: user?.id, },