diff --git a/.changeset/young-impalas-grab.md b/.changeset/young-impalas-grab.md new file mode 100644 index 00000000000..70fbd40bd78 --- /dev/null +++ b/.changeset/young-impalas-grab.md @@ -0,0 +1,14 @@ +--- +'@clerk/shared': patch +--- + +Relaxing requirements for RQ variant hooks to enable revalidation across different configurations of the same hook. + +```tsx + +const { revalidate } = useStatements({ initialPage: 1, pageSize: 10 }); +useStatements({ initialPage: 1, pageSize: 12 }); + +// revalidate from first hook, now invalidates the second hook. +void revalidate(); +``` diff --git a/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx b/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx index dd8cc689a52..d482c968cdb 100644 --- a/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ClerkResource } from '../../../types'; @@ -486,4 +486,66 @@ describe('createBillingPaginatedHook', () => { expect(result.current.pageCount).toBe(5); }); }); + + describe('revalidate behavior', () => { + it('revalidate fetches fresh data for authenticated hook', async () => { + fetcherMock + .mockResolvedValueOnce({ + data: [{ id: 'initial-1' } as DummyResource, { id: 'initial-2' } as DummyResource], + total_count: 2, + }) + .mockResolvedValueOnce({ + data: [{ id: 'refetched-1' } as DummyResource, { id: 'refetched-2' } as DummyResource], + total_count: 2, + }); + + const { result } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 2 }), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual([{ id: 'initial-1' }, { id: 'initial-2' }]); + + await act(async () => { + await result.current.revalidate(); + }); + + await waitFor(() => expect(result.current.data).toEqual([{ id: 'refetched-1' }, { id: 'refetched-2' }])); + expect(fetcherMock).toHaveBeenCalledTimes(2); + }); + + it('revalidate propagates to infinite counterpart only for React Query', async () => { + let seq = 0; + fetcherMock.mockImplementation(async (params: DummyParams) => { + seq++; + return { + data: Array.from({ length: params.pageSize ?? 2 }, (_, i) => ({ + id: `item-${params.initialPage ?? 1}-${seq}-${i}`, + })) as DummyResource[], + total_count: 10, + }; + }); + + const useBoth = () => { + const paginated = useDummyAuth({ initialPage: 1, pageSize: 2 }); + const infinite = useDummyAuth({ initialPage: 1, pageSize: 2, infinite: true } as any); + return { paginated, infinite }; + }; + + const { result } = renderHook(useBoth, { wrapper }); + + await waitFor(() => expect(result.current.paginated.isLoading).toBe(false)); + await waitFor(() => expect(result.current.infinite.isLoading).toBe(false)); + + fetcherMock.mockClear(); + + await act(async () => { + await result.current.paginated.revalidate(); + }); + + if (__CLERK_USE_RQ__) { + await waitFor(() => expect(fetcherMock.mock.calls.length).toBeGreaterThanOrEqual(2)); + } else { + await waitFor(() => expect(fetcherMock).toHaveBeenCalledTimes(1)); + } + }); + }); }); diff --git a/packages/shared/src/react/hooks/__tests__/useAPIKeys.spec.tsx b/packages/shared/src/react/hooks/__tests__/useAPIKeys.spec.tsx new file mode 100644 index 00000000000..1576cccd9be --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/useAPIKeys.spec.tsx @@ -0,0 +1,213 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAPIKeys } from '../useAPIKeys'; +import { createMockClerk, createMockQueryClient } from './mocks/clerk'; +import { wrapper } from './wrapper'; + +const getAllSpy = vi.fn( + async () => + ({ + data: [], + total_count: 0, + }) as { data: Array>; total_count: number }, +); + +const defaultQueryClient = createMockQueryClient(); + +const mockClerk = createMockClerk({ + apiKeys: { + getAll: getAllSpy, + }, + queryClient: defaultQueryClient, +}); + +vi.mock('../../contexts', () => { + return { + useAssertWrappedByClerkProvider: () => {}, + useClerkInstanceContext: () => mockClerk, + }; +}); + +describe('useApiKeys', () => { + beforeEach(() => { + vi.clearAllMocks(); + defaultQueryClient.client.clear(); + mockClerk.loaded = true; + mockClerk.user = { id: 'user_1' }; + }); + + it('revalidate fetches fresh API keys', async () => { + getAllSpy + .mockResolvedValueOnce({ + data: [{ id: 'key_initial' }], + total_count: 1, + }) + .mockResolvedValueOnce({ + data: [{ id: 'key_updated' }], + total_count: 1, + }); + + const { result } = renderHook(() => useAPIKeys({ subject: 'user_1', pageSize: 1 }), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual([{ id: 'key_initial' }]); + + await act(async () => { + await result.current.revalidate(); + }); + + await waitFor(() => expect(result.current.data).toEqual([{ id: 'key_updated' }])); + expect(getAllSpy).toHaveBeenCalledTimes(2); + }); + + it('cascades revalidation for related queries only when using React Query', async () => { + let sequence = 0; + getAllSpy.mockImplementation(async ({ initialPage }: { initialPage?: number } = {}) => { + sequence += 1; + const page = initialPage ?? 1; + return { + data: [{ id: `key-${page}-${sequence}` }], + total_count: 5, + }; + }); + + const useBoth = () => { + const paginated = useAPIKeys({ subject: 'user_1', pageSize: 1 }); + const infinite = useAPIKeys({ subject: 'user_1', pageSize: 1, infinite: true }); + return { paginated, infinite }; + }; + + const { result } = renderHook(useBoth, { wrapper }); + + await waitFor(() => expect(result.current.paginated.isLoading).toBe(false)); + await waitFor(() => expect(result.current.infinite.isLoading).toBe(false)); + + getAllSpy.mockClear(); + + await act(async () => { + await result.current.paginated.revalidate(); + }); + + const isRQ = Boolean((globalThis as any).__CLERK_USE_RQ__); + + if (isRQ) { + await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(2)); + } else { + await waitFor(() => expect(getAllSpy).toHaveBeenCalledTimes(1)); + } + }); + + it('handles revalidation with different pageSize configurations', async () => { + let seq = 0; + getAllSpy.mockImplementation(async ({ pageSize }: { pageSize?: number } = {}) => { + seq += 1; + return { + data: [{ id: `key-pageSize-${pageSize ?? 'unknown'}-${seq}` }], + total_count: 3, + }; + }); + + const useHooks = () => { + const small = useAPIKeys({ subject: 'user_1', pageSize: 1 }); + const large = useAPIKeys({ subject: 'user_1', pageSize: 5 }); + return { small, large }; + }; + + const { result } = renderHook(useHooks, { wrapper }); + + await waitFor(() => expect(result.current.small.isLoading).toBe(false)); + await waitFor(() => expect(result.current.large.isLoading).toBe(false)); + + getAllSpy.mockClear(); + + await act(async () => { + await result.current.small.revalidate(); + }); + + const isRQ = Boolean((globalThis as any).__CLERK_USE_RQ__); + + await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(1)); + + if (isRQ) { + await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(2)); + } else { + expect(getAllSpy).toHaveBeenCalledTimes(1); + } + }); + + it('handles revalidation with different query filters', async () => { + let seq = 0; + getAllSpy.mockImplementation(async ({ query }: { query?: string } = {}) => { + seq += 1; + return { + data: [{ id: `key-query-${query ?? 'empty'}-${seq}` }], + total_count: 2, + }; + }); + + const useHooks = () => { + const defaultQuery = useAPIKeys({ subject: 'user_1', pageSize: 11, query: '' }); + const filtered = useAPIKeys({ subject: 'user_1', pageSize: 11, query: 'search' }); + return { defaultQuery, filtered }; + }; + + const { result } = renderHook(useHooks, { wrapper }); + + await waitFor(() => expect(result.current.defaultQuery.isLoading).toBe(false)); + await waitFor(() => expect(result.current.filtered.isLoading).toBe(false)); + + getAllSpy.mockClear(); + + await act(async () => { + await result.current.defaultQuery.revalidate(); + }); + + const isRQ = Boolean((globalThis as any).__CLERK_USE_RQ__); + + await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(1)); + + if (isRQ) { + await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(2)); + } else { + expect(getAllSpy).toHaveBeenCalledTimes(1); + } + }); + + it('does not cascade revalidation across different subjects', async () => { + let seq = 0; + getAllSpy.mockImplementation(async ({ subject }: { subject?: string } = {}) => { + seq += 1; + return { + data: [{ id: `key-subject-${subject ?? 'none'}-${seq}` }], + total_count: 4, + }; + }); + + const useHooks = () => { + const primary = useAPIKeys({ subject: 'user_primary', pageSize: 1 }); + const secondary = useAPIKeys({ subject: 'user_secondary', pageSize: 1 }); + return { primary, secondary }; + }; + + const { result } = renderHook(useHooks, { wrapper }); + + await waitFor(() => expect(result.current.primary.isLoading).toBe(false)); + await waitFor(() => expect(result.current.secondary.isLoading).toBe(false)); + + getAllSpy.mockClear(); + + await act(async () => { + await result.current.primary.revalidate(); + }); + + await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(1)); + + expect(getAllSpy).toHaveBeenCalledTimes(1); + const subjects = (getAllSpy.mock.calls as Array).map( + call => (call[0] as { subject?: string } | undefined)?.subject, + ); + expect(subjects).not.toContain('user_secondary'); + expect(subjects[0] === undefined || subjects[0] === 'user_primary').toBe(true); + }); +}); diff --git a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts index 08b95f06ad2..7d456bd4316 100644 --- a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts +++ b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts @@ -503,6 +503,35 @@ describe('usePagesOrInfinite - behaviors mirrored from useCoreOrganization', () }); describe('usePagesOrInfinite - revalidate behavior', () => { + it('refetches current data when revalidate is invoked', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce({ + data: [{ id: 'initial-1' }], + total_count: 1, + }) + .mockResolvedValueOnce({ + data: [{ id: 'refetched-1' }], + total_count: 1, + }); + + const params = { initialPage: 1, pageSize: 1 }; + const config = buildConfig(params); + const keys = buildKeys('t-revalidate-refresh', params); + + const { result } = renderUsePagesOrInfinite({ fetcher, config, keys }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual([{ id: 'initial-1' }]); + + await act(async () => { + await (result.current as any).revalidate(); + }); + + await waitFor(() => expect(result.current.data).toEqual([{ id: 'refetched-1' }])); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + it('pagination mode: isFetching toggles during revalidate, isLoading stays false after initial load', async () => { const deferred = createDeferredPromise(); let callCount = 0; @@ -652,6 +681,47 @@ describe('usePagesOrInfinite - revalidate behavior', () => { { id: 'p2-1-revalidate' }, ]); }); + + it('cascades revalidation to related queries only in React Query mode', async () => { + const params = { initialPage: 1, pageSize: 1 }; + const keys = buildKeys('t-revalidate-cascade', params, { userId: 'user_123' }); + const fetcher = vi.fn(async ({ initialPage }: any) => ({ + data: [{ id: `item-${initialPage}-${fetcher.mock.calls.length}` }], + total_count: 3, + })); + + const useBoth = () => { + const paginated = usePagesOrInfinite({ + fetcher, + config: buildConfig(params), + keys, + }); + const infinite = usePagesOrInfinite({ + fetcher, + config: buildConfig(params, { infinite: true }), + keys, + }); + + return { paginated, infinite }; + }; + + const { result } = renderHook(useBoth, { wrapper }); + + await waitFor(() => expect(result.current.paginated.isLoading).toBe(false)); + await waitFor(() => expect(result.current.infinite.isLoading).toBe(false)); + + fetcher.mockClear(); + + await act(async () => { + await result.current.paginated.revalidate(); + }); + + if (__CLERK_USE_RQ__) { + await waitFor(() => expect(fetcher.mock.calls.length).toBeGreaterThanOrEqual(2)); + } else { + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + } + }); }); describe('usePagesOrInfinite - error propagation', () => { diff --git a/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx b/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx index 7c9965b7ecd..a240759d779 100644 --- a/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx @@ -1,4 +1,4 @@ -import { render, renderHook, screen, waitFor } from '@testing-library/react'; +import { act, render, renderHook, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -203,4 +203,77 @@ describe('usePlans', () => { expect(result.current.data.length).toBe(5); expect(result.current.count).toBe(25); }); + + it('revalidate refetches plans and updates cache', async () => { + const firstResponse = { + data: [{ id: 'plan_initial', forPayerType: 'user' } as Partial], + total_count: 1, + }; + + const secondResponse = { + data: [{ id: 'plan_updated', forPayerType: 'user' } as Partial], + total_count: 1, + }; + + getPlansSpy.mockImplementationOnce(() => Promise.resolve(firstResponse)); + getPlansSpy.mockImplementationOnce(() => Promise.resolve(secondResponse)); + + const { result } = renderHook(() => usePlans({ initialPage: 1, pageSize: 1 }), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual(firstResponse.data); + + await act(async () => { + await result.current.revalidate(); + }); + + await waitFor(() => expect(result.current.data).toEqual(secondResponse.data)); + expect(getPlansSpy).toHaveBeenCalledTimes(2); + }); + + it('revalidate for user plans does not refetch organization plans', async () => { + getPlansSpy.mockImplementation(({ for: forParam, initialPage, pageSize }) => + Promise.resolve({ + data: [ + { + id: `${forParam}-plan-${initialPage}-${pageSize}`, + forPayerType: forParam, + } as Partial, + ], + total_count: 1, + }), + ); + + const useBoth = () => { + const userPlans = usePlans({ initialPage: 1, pageSize: 1 }); + const orgPlans = usePlans({ initialPage: 1, pageSize: 1, for: 'organization' } as any); + return { userPlans, orgPlans }; + }; + + const { result } = renderHook(useBoth, { wrapper }); + + await waitFor(() => expect(result.current.userPlans.isLoading).toBe(false)); + await waitFor(() => expect(result.current.orgPlans.isLoading).toBe(false)); + + getPlansSpy.mockClear(); + + await act(async () => { + await result.current.userPlans.revalidate(); + }); + + const isRQ = Boolean((globalThis as any).__CLERK_USE_RQ__); + const calls = getPlansSpy.mock.calls.map(call => call[0]?.for); + + if (isRQ) { + await waitFor(() => expect(getPlansSpy.mock.calls.length).toBeGreaterThanOrEqual(1)); + expect(calls.every(value => value === 'user')).toBe(true); + } else { + await waitFor(() => expect(getPlansSpy.mock.calls.length).toBe(1)); + expect(getPlansSpy.mock.calls[0][0]).toEqual( + expect.objectContaining({ + for: 'user', + }), + ); + } + }); }); diff --git a/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx b/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx index 96cd2870280..69fe3d7bb4c 100644 --- a/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createDeferredPromise } from '../../../utils/createDeferredPromise'; @@ -213,4 +213,22 @@ describe('useSubscription', () => { expect(result.current.isLoading).toBe(false); expect(getSubscriptionSpy).toHaveBeenCalledTimes(2); }); + + it('revalidate fetches the latest subscription data', async () => { + getSubscriptionSpy + .mockImplementationOnce(() => Promise.resolve({ id: 'sub_user_initial' })) + .mockImplementationOnce(() => Promise.resolve({ id: 'sub_user_refetched' })); + + const { result } = renderHook(() => useSubscription(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual({ id: 'sub_user_initial' }); + + await act(async () => { + await result.current.revalidate(); + }); + + await waitFor(() => expect(result.current.data).toEqual({ id: 'sub_user_refetched' })); + expect(getSubscriptionSpy).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/shared/src/react/hooks/usePageOrInfinite.types.ts b/packages/shared/src/react/hooks/usePageOrInfinite.types.ts index 7e71c72e48a..5fe1d831456 100644 --- a/packages/shared/src/react/hooks/usePageOrInfinite.types.ts +++ b/packages/shared/src/react/hooks/usePageOrInfinite.types.ts @@ -6,23 +6,6 @@ export type ExtractData = Type extends { data: infer Data } ? ArrayType - ? TQueryKey - : TQueryKey extends Array - ? Readonly - : ReadonlyArray - : ReadonlyArray; - type QueryArgs = Readonly<{ args: Params; }>; @@ -35,12 +18,14 @@ type QueryKeyWithArgs = readonly [ ...Array, ]; +type InvalidationQueryKey = readonly [string, boolean, Record]; + export type UsePagesOrInfiniteSignature = < Params, FetcherReturnData extends Record, TCacheKeys extends { queryKey: QueryKeyWithArgs; - invalidationKey: AnyQueryKey; + invalidationKey: InvalidationQueryKey; stableKey: string; }, TConfig extends Config = Config, diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx b/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx index e33749ae230..ee24a146c09 100644 --- a/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx @@ -265,11 +265,10 @@ export const usePagesOrInfinite: UsePagesOrInfiniteSignature = params => { return Promise.resolve(); }; - const revalidate = () => { - if (triggerInfinite) { - return queryClient.invalidateQueries({ queryKey: infiniteQueryKey }); - } - return queryClient.invalidateQueries({ queryKey: pagesQueryKey }); + const revalidate = async () => { + await queryClient.invalidateQueries({ queryKey: keys.invalidationKey }); + const [stablePrefix, ...rest] = keys.invalidationKey; + return queryClient.invalidateQueries({ queryKey: [stablePrefix + '-inf', ...rest] }); }; return {