diff --git a/.changeset/soft-beers-sit.md b/.changeset/soft-beers-sit.md new file mode 100644 index 00000000000..35701cc0040 --- /dev/null +++ b/.changeset/soft-beers-sit.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Support `keepPreviousData` behaviour in the internal React Query variant of `useSubscription`. diff --git a/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx b/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx index f4c5e7d0750..96cd2870280 100644 --- a/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx @@ -1,6 +1,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createDeferredPromise } from '../../../utils/createDeferredPromise'; import { useSubscription } from '../useSubscription'; import { createMockClerk, createMockOrganization, createMockQueryClient, createMockUser } from './mocks/clerk'; import { wrapper } from './wrapper'; @@ -144,4 +145,72 @@ describe('useSubscription', () => { expect(getSubscriptionSpy).toHaveBeenCalledTimes(1); expect(result.current.isFetching).toBe(false); }); + + it('retains previous data while refetching when keepPreviousData=true', async () => { + const { result, rerender } = renderHook( + ({ orgId, keepPreviousData }) => { + mockOrganization = createMockOrganization({ id: orgId }); + return useSubscription({ for: 'organization', keepPreviousData }); + }, + { + wrapper, + initialProps: { orgId: 'org_1', keepPreviousData: true }, + }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual({ id: 'sub_org_org_1' }); + + const deferred = createDeferredPromise(); + getSubscriptionSpy.mockImplementationOnce(() => deferred.promise as Promise<{ id: string }>); + + rerender({ orgId: 'org_2', keepPreviousData: true }); + + await waitFor(() => expect(result.current.isFetching).toBe(true)); + + // Slight difference in behavior between SWR and React Query, but acceptable for the migration. + if (__CLERK_USE_RQ__) { + await waitFor(() => expect(result.current.isLoading).toBe(false)); + } else { + await waitFor(() => expect(result.current.isLoading).toBe(true)); + } + expect(result.current.data).toEqual({ id: 'sub_org_org_1' }); + + deferred.resolve({ id: 'sub_org_org_2' }); + + await waitFor(() => expect(result.current.data).toEqual({ id: 'sub_org_org_2' })); + expect(getSubscriptionSpy).toHaveBeenCalledTimes(2); + }); + + it('clears data while refetching when keepPreviousData=false', async () => { + const { result, rerender } = renderHook( + ({ orgId, keepPreviousData }) => { + mockOrganization = createMockOrganization({ id: orgId }); + return useSubscription({ for: 'organization', keepPreviousData }); + }, + { + wrapper, + initialProps: { orgId: 'org_1', keepPreviousData: false }, + }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual({ id: 'sub_org_org_1' }); + + const deferred = createDeferredPromise(); + getSubscriptionSpy.mockImplementationOnce(() => deferred.promise as Promise<{ id: string }>); + + rerender({ orgId: 'org_2', keepPreviousData: false }); + + await waitFor(() => expect(result.current.isFetching).toBe(true)); + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeUndefined(); + + deferred.resolve({ id: 'sub_org_org_2' }); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + expect(result.current.data).toEqual({ id: 'sub_org_org_2' }); + expect(result.current.isLoading).toBe(false); + expect(getSubscriptionSpy).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx b/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx index cefb606e1fc..870b0428018 100644 --- a/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx +++ b/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx @@ -105,8 +105,10 @@ export function createBillingPaginatedHook), enabled: isEnabled, ...(options?.unauthenticated ? {} : { isSignedIn: Boolean(user) }), __experimental_mode: safeValues.__experimental_mode, diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx b/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx index c7a2770b037..cdc77b2bed2 100644 --- a/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.rq.tsx @@ -11,6 +11,13 @@ import type { UsePagesOrInfiniteSignature } from './usePageOrInfinite.types'; import { getDifferentKeys, useWithSafeValues } from './usePagesOrInfinite.shared'; import { usePreviousValue } from './usePreviousValue'; +/** + * @internal + */ +function KeepPreviousDataFn(previousData: Data): Data { + return previousData; +} + export const usePagesOrInfinite: UsePagesOrInfiniteSignature = (params, fetcher, config, cacheKeys) => { const [paginatedPage, setPaginatedPage] = useState(params.initialPage ?? 1); @@ -65,7 +72,7 @@ export const usePagesOrInfinite: UsePagesOrInfiniteSignature = (params, fetcher, staleTime: 60_000, enabled: queriesEnabled && !triggerInfinite, // Use placeholderData to keep previous data while fetching new page - placeholderData: keepPreviousData ? previousData => previousData : undefined, + placeholderData: keepPreviousData ? KeepPreviousDataFn : undefined, }); // Infinite mode: accumulate pages diff --git a/packages/shared/src/react/hooks/useSubscription.rq.tsx b/packages/shared/src/react/hooks/useSubscription.rq.tsx index 3722086d711..d6a117f27cb 100644 --- a/packages/shared/src/react/hooks/useSubscription.rq.tsx +++ b/packages/shared/src/react/hooks/useSubscription.rq.tsx @@ -14,6 +14,13 @@ import type { SubscriptionResult, UseSubscriptionParams } from './useSubscriptio const HOOK_NAME = 'useSubscription'; +/** + * @internal + */ +function KeepPreviousDataFn(previousData: Data): Data { + return previousData; +} + /** * This is the new implementation of useSubscription using React Query. * It is exported only if the package is build with the `CLERK_USE_RQ` environment variable set to `true`. @@ -36,6 +43,7 @@ export function useSubscription(params?: UseSubscriptionParams): SubscriptionRes const billingEnabled = isOrganization ? environment?.commerceSettings.billing.organization.enabled : environment?.commerceSettings.billing.user.enabled; + const keepPreviousData = params?.keepPreviousData ?? false; const [queryClient] = useClerkQueryClient(); @@ -59,7 +67,7 @@ export function useSubscription(params?: UseSubscriptionParams): SubscriptionRes }, staleTime: 1_000 * 60, enabled: queriesEnabled, - // TODO(@RQ_MIGRATION): Add support for keepPreviousData + placeholderData: keepPreviousData && queriesEnabled ? KeepPreviousDataFn : undefined, }); const revalidate = useCallback(() => queryClient.invalidateQueries({ queryKey }), [queryClient, queryKey]);