diff --git a/.changeset/seven-turkeys-sin.md b/.changeset/seven-turkeys-sin.md new file mode 100644 index 00000000000..4a437c78c0f --- /dev/null +++ b/.changeset/seven-turkeys-sin.md @@ -0,0 +1,7 @@ +--- +'@clerk/shared': patch +--- + +Bug fix for billing hooks that would sometimes fire requests while the user was signed out. + +Improves the `usePlan` hook has been updated to not fire requests when switching organizations or when users sign in/out. diff --git a/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx b/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx new file mode 100644 index 00000000000..da7b556adc0 --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx @@ -0,0 +1,402 @@ +import type { ClerkResource } from '@clerk/types'; +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createBillingPaginatedHook } from '../createBillingPaginatedHook'; +import { wrapper } from './wrapper'; + +// Mocks for contexts +let mockUser: any = { id: 'user_1' }; +let mockOrganization: any = { id: 'org_1' }; + +const mockClerk = { + loaded: true, + __unstable__environment: { + commerceSettings: { + billing: { + user: { enabled: true }, + organization: { enabled: true }, + }, + }, + }, +}; + +vi.mock('../../contexts', () => { + return { + useAssertWrappedByClerkProvider: () => {}, + useClerkInstanceContext: () => mockClerk, + useUserContext: () => (mockClerk.loaded ? mockUser : null), + useOrganizationContext: () => ({ organization: mockClerk.loaded ? mockOrganization : null }), + }; +}); + +type DummyResource = { id: string } & ClerkResource; +type DummyParams = { initialPage?: number; pageSize?: number } & { orgId?: string }; + +const fetcherMock = vi.fn(); +const useFetcherMock = vi.fn(() => fetcherMock); + +const useDummyAuth = createBillingPaginatedHook({ + hookName: 'useDummyAuth', + resourceType: 'dummy', + useFetcher: useFetcherMock, +}); + +const useDummyUnauth = createBillingPaginatedHook({ + hookName: 'useDummyUnauth', + resourceType: 'dummy', + useFetcher: useFetcherMock, + options: { unauthenticated: true }, +}); + +describe('createBillingPaginatedHook', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockClerk.loaded = true; + mockClerk.__unstable__environment.commerceSettings.billing.user.enabled = true; + mockClerk.__unstable__environment.commerceSettings.billing.organization.enabled = true; + mockUser = { id: 'user_1' }; + mockOrganization = { id: 'org_1' }; + }); + + it('fetches with default params when called with no params', async () => { + const { result } = renderHook(() => useDummyAuth(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(useFetcherMock).toHaveBeenCalledWith('user'); + expect(fetcherMock).toHaveBeenCalled(); + + // Assert default params + expect(fetcherMock.mock.calls[0][0]).toStrictEqual({ initialPage: 1, pageSize: 10 }); + }); + + it('does not fetch when clerk.loaded is false', () => { + mockClerk.loaded = false; + + const { result } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 5 }), { wrapper }); + + // useFetcher is invoked eagerly, but the returned function should not be called + expect(useFetcherMock).toHaveBeenCalledWith('user'); + + expect(fetcherMock).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toEqual([]); + }); + + it('does not fetch when billing disabled (user)', () => { + mockClerk.__unstable__environment.commerceSettings.billing.user.enabled = false; + + const { result } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 4 }), { wrapper }); + + expect(useFetcherMock).toHaveBeenCalledWith('user'); + + expect(fetcherMock).not.toHaveBeenCalled(); + // Ensures that SWR does not update the loading state even if the fetcher is not called. + expect(result.current.isLoading).toBe(false); + expect(result.current.isFetching).toBe(false); + }); + + it('authenticated hook: does not fetch when user is null', () => { + mockUser = null; + + const { result } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 4 }), { wrapper }); + + expect(useFetcherMock).toHaveBeenCalledWith('user'); + + expect(fetcherMock).not.toHaveBeenCalled(); + expect(result.current.data).toEqual([]); + }); + + it('unauthenticated hook: fetches even when user is null', async () => { + mockUser = null; + + const { result } = renderHook(() => useDummyUnauth({ initialPage: 1, pageSize: 4 }), { wrapper }); + + expect(useFetcherMock).toHaveBeenCalledWith('user'); + await waitFor(() => expect(result.current.isLoading).toBe(true)); + expect(fetcherMock).toHaveBeenCalled(); + expect(fetcherMock.mock.calls[0][0]).toStrictEqual({ initialPage: 1, pageSize: 4 }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + }); + + it('unauthenticated hook: does not fetch when billing disabled for both user and organization', () => { + mockUser = null; + mockClerk.__unstable__environment.commerceSettings.billing.user.enabled = false; + mockClerk.__unstable__environment.commerceSettings.billing.organization.enabled = false; + + const { result } = renderHook(() => useDummyUnauth({ initialPage: 1, pageSize: 4 }), { wrapper }); + + expect(useFetcherMock).toHaveBeenCalledWith('user'); + expect(fetcherMock).not.toHaveBeenCalled(); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toEqual([]); + }); + + it('allows fetching for user when organization billing disabled', async () => { + mockClerk.__unstable__environment.commerceSettings.billing.organization.enabled = false; + mockClerk.__unstable__environment.commerceSettings.billing.user.enabled = true; + + const { result } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 4 }), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(true)); + expect(useFetcherMock).toHaveBeenCalledWith('user'); + expect(fetcherMock.mock.calls[0][0]).toStrictEqual({ initialPage: 1, pageSize: 4 }); + }); + + it('when for=organization orgId should be forwarded to fetcher', async () => { + const { result } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 4, for: 'organization' } as any), { + wrapper, + }); + + await waitFor(() => expect(result.current.isLoading).toBe(true)); + expect(useFetcherMock).toHaveBeenCalledWith('organization'); + expect(fetcherMock.mock.calls[0][0]).toStrictEqual({ + initialPage: 1, + pageSize: 4, + orgId: 'org_1', + }); + }); + + it('does not fetch in organization mode when organization billing disabled', async () => { + mockClerk.__unstable__environment.commerceSettings.billing.organization.enabled = false; + + const { result } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 4, for: 'organization' } as any), { + wrapper, + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(useFetcherMock).toHaveBeenCalledWith('organization'); + expect(fetcherMock).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + }); + + it('unauthenticated hook: does not fetch in organization mode when organization billing disabled', async () => { + mockClerk.__unstable__environment.commerceSettings.billing.organization.enabled = false; + + const { result } = renderHook(() => useDummyUnauth({ initialPage: 1, pageSize: 4, for: 'organization' } as any), { + wrapper, + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(useFetcherMock).toHaveBeenCalledWith('organization'); + expect(fetcherMock).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + }); + + describe('authenticated hook - after sign-out previously loaded data are cleared', () => { + it('pagination mode: data is cleared when user signs out', async () => { + fetcherMock.mockImplementation((params: any) => + Promise.resolve({ + data: Array.from({ length: params.pageSize }, (_, i) => ({ id: `p${params.initialPage}-${i}` })), + total_count: 5, + }), + ); + + const { result, rerender } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 2 }), { + wrapper, + }); + + await waitFor(() => expect(result.current.isFetching).toBe(true)); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + expect(result.current.data.length).toBe(2); + expect(result.current.page).toBe(1); + expect(result.current.pageCount).toBe(3); // ceil(5/2) + + // Simulate sign-out + mockUser = null; + rerender(); + + // Data should become empty + await waitFor(() => expect(result.current.data).toEqual([])); + expect(result.current.count).toBe(0); + expect(result.current.page).toBe(1); + expect(result.current.pageCount).toBe(0); + }); + + it('pagination mode: with keepPreviousData=true data is cleared after sign-out', async () => { + fetcherMock.mockImplementation((params: any) => + Promise.resolve({ + data: Array.from({ length: params.pageSize }, (_, i) => ({ id: `item-${params.initialPage}-${i}` })), + total_count: 20, + }), + ); + + const { result, rerender } = renderHook( + () => useDummyAuth({ initialPage: 1, pageSize: 5, keepPreviousData: true }), + { + wrapper, + }, + ); + + // Wait for initial data load + await waitFor(() => expect(result.current.isLoading).toBe(true)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.data.length).toBe(5); + expect(result.current.data).toEqual([ + { id: 'item-1-0' }, + { id: 'item-1-1' }, + { id: 'item-1-2' }, + { id: 'item-1-3' }, + { id: 'item-1-4' }, + ]); + expect(result.current.count).toBe(20); + + // Simulate sign-out by setting mockUser to null + mockUser = null; + rerender(); + + // Attention: We are forcing fetcher to be executed instead of setting the key to null + // because SWR will continue to display the cached data when the key is null and `keepPreviousData` is true. + // This means that SWR will update the loading state to true even if the fetcher is not called, + // because the key changes from `{..., userId: 'user_1'}` to `{..., userId: undefined}`. + await waitFor(() => expect(result.current.isLoading).toBe(true)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + // Data should be cleared even with keepPreviousData: true + // The key difference here vs usePagesOrInfinite test: userId in cache key changes + // from 'user_1' to undefined, which changes the cache key (not just makes it null) + await waitFor(() => expect(result.current.data).toEqual([])); + expect(result.current.count).toBe(0); + expect(result.current.page).toBe(1); + expect(result.current.pageCount).toBe(0); + }); + + it('infinite mode: data is cleared when user signs out', async () => { + fetcherMock.mockImplementation((params: any) => + Promise.resolve({ + data: Array.from({ length: params.pageSize }, (_, i) => ({ id: `p${params.initialPage}-${i}` })), + total_count: 10, + }), + ); + + const { result, rerender } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 2, infinite: true }), { + wrapper, + }); + + await waitFor(() => expect(result.current.isFetching).toBe(true)); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + expect(result.current.data.length).toBe(2); + expect(result.current.page).toBe(1); + expect(result.current.pageCount).toBe(5); // ceil(10/2) + + // Simulate sign-out + mockUser = null; + rerender(); + + await waitFor(() => expect(result.current.data).toEqual([])); + expect(result.current.count).toBe(0); + expect(result.current.page).toBe(1); + expect(result.current.pageCount).toBe(0); + }); + }); + + describe('unauthenticated hook - data persists after sign-out', () => { + it('pagination mode: data persists when user signs out', async () => { + fetcherMock.mockImplementation((params: any) => + Promise.resolve({ + data: Array.from({ length: params.pageSize }, (_, i) => ({ id: `p${params.initialPage}-${i}` })), + total_count: 5, + }), + ); + + const { result, rerender } = renderHook(() => useDummyUnauth({ initialPage: 1, pageSize: 2 }), { + wrapper, + }); + + await waitFor(() => expect(result.current.isFetching).toBe(true)); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + expect(result.current.data.length).toBe(2); + expect(result.current.page).toBe(1); + expect(result.current.pageCount).toBe(3); // ceil(5/2) + + const originalData = [...result.current.data]; + const originalCount = result.current.count; + + // Simulate sign-out + mockUser = null; + rerender(); + + // Data should persist for unauthenticated hooks + expect(result.current.data).toEqual(originalData); + expect(result.current.count).toBe(originalCount); + expect(result.current.page).toBe(1); + expect(result.current.pageCount).toBe(3); + }); + + it('pagination mode: with keepPreviousData=true data persists after sign-out', async () => { + fetcherMock.mockImplementation((params: any) => + Promise.resolve({ + data: Array.from({ length: params.pageSize }, (_, i) => ({ id: `item-${params.initialPage}-${i}` })), + total_count: 20, + }), + ); + + const { result, rerender } = renderHook( + () => useDummyUnauth({ initialPage: 1, pageSize: 5, keepPreviousData: true }), + { + wrapper, + }, + ); + + // Wait for initial data load + await waitFor(() => expect(result.current.isLoading).toBe(true)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.data.length).toBe(5); + expect(result.current.data).toEqual([ + { id: 'item-1-0' }, + { id: 'item-1-1' }, + { id: 'item-1-2' }, + { id: 'item-1-3' }, + { id: 'item-1-4' }, + ]); + expect(result.current.count).toBe(20); + + const originalData = [...result.current.data]; + + // Simulate sign-out by setting mockUser to null + mockUser = null; + rerender(); + + // Data should persist for unauthenticated hooks even with keepPreviousData: true + expect(result.current.data).toEqual(originalData); + expect(result.current.count).toBe(20); + expect(result.current.page).toBe(1); + expect(result.current.pageCount).toBe(4); // ceil(20/5) + }); + + it('infinite mode: data persists when user signs out', async () => { + fetcherMock.mockImplementation((params: any) => + Promise.resolve({ + data: Array.from({ length: params.pageSize }, (_, i) => ({ id: `p${params.initialPage}-${i}` })), + total_count: 10, + }), + ); + + const { result, rerender } = renderHook(() => useDummyUnauth({ initialPage: 1, pageSize: 2, infinite: true }), { + wrapper, + }); + + await waitFor(() => expect(result.current.isFetching).toBe(true)); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + expect(result.current.data.length).toBe(2); + expect(result.current.page).toBe(1); + expect(result.current.pageCount).toBe(5); // ceil(10/2) + + const originalData = [...result.current.data]; + const originalCount = result.current.count; + + // Simulate sign-out + mockUser = null; + rerender(); + + // Data should persist for unauthenticated hooks + expect(result.current.data).toEqual(originalData); + expect(result.current.count).toBe(originalCount); + expect(result.current.page).toBe(1); + expect(result.current.pageCount).toBe(5); + }); + }); +}); diff --git a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts new file mode 100644 index 00000000000..0bb871e4b58 --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts @@ -0,0 +1,646 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createDeferredPromise } from '../../../utils/createDeferredPromise'; +import { usePagesOrInfinite } from '../usePagesOrInfinite'; +import { wrapper } from './wrapper'; + +describe('usePagesOrInfinite - basic pagination', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses SWR with merged key and fetcher params; maps data and count', async () => { + const fetcher = vi.fn(async (p: any) => { + // simulate API returning paginated response + return { + data: Array.from({ length: p.pageSize }, (_, i) => ({ id: `item-${p.initialPage}-${i}` })), + total_count: 42, + }; + }); + + const params = { initialPage: 2, pageSize: 5 } as const; + const config = { infinite: false, keepPreviousData: true } as const; + const cacheKeys = { type: 't-basic', userId: 'user_123' } as const; + + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + + // wait until SWR mock finishes fetching + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + // ensure fetcher received params without cache keys and with page info + expect(fetcher).toHaveBeenCalledTimes(1); + const calledWith = fetcher.mock.calls[0][0]; + expect(calledWith).toMatchObject({ initialPage: 2, pageSize: 5 }); + expect(calledWith.type).toBeUndefined(); + expect(calledWith.userId).toBeUndefined(); + + // hook result mapping + expect(result.current.isLoading).toBe(false); + expect(result.current.page).toBe(2); + expect(result.current.data).toHaveLength(5); + expect(result.current.count).toBe(42); + + // pageCount calculation considers initialPage offset + // offset = (2-1)*5 = 5; remaining = 42-5 = 37; pageCount = ceil(37/5) = 8 + expect(result.current.pageCount).toBe(8); + + // validate helpers update page state + act(() => { + result.current.fetchNext(); + }); + expect(result.current.page).toBe(3); + + act(() => { + result.current.fetchPrevious(); + }); + expect(result.current.page).toBe(2); + + // setData should update cached data without revalidation + await act(async () => { + await (result.current as any).setData((prev: any) => ({ ...prev, data: [{ id: 'mutated' }] })); + }); + expect(result.current.data).toEqual([{ id: 'mutated' }]); + }); +}); + +describe('usePagesOrInfinite - request params and getDifferentKeys', () => { + it('calls fetcher with merged params and strips cache keys; updates params on page change', async () => { + const fetcher = vi.fn((p: any) => + Promise.resolve({ + data: Array.from({ length: p.pageSize }, (_, i) => ({ id: `row-${p.initialPage}-${i}` })), + total_count: 6, + }), + ); + + const params = { initialPage: 2, pageSize: 3, someFilter: 'A' } as const; + const cacheKeys = { type: 't-params', userId: 'user_42' } as const; + const config = { infinite: false, enabled: true } as const; + + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(true)); + + // First call: should include provided params, not include cache keys + expect(fetcher).toHaveBeenCalledTimes(1); + const first = fetcher.mock.calls[0][0]; + expect(first).toStrictEqual({ initialPage: 2, pageSize: 3, someFilter: 'A' }); + expect(first.type).toBeUndefined(); + expect(first.userId).toBeUndefined(); + + // Move to next page: getDifferentKeys should provide updated initialPage to fetcher + act(() => { + result.current.fetchNext(); + }); + + await waitFor(() => expect(result.current.page).toBe(3)); + // The next call should have initialPage updated to 3 + const second = fetcher.mock.calls[1][0]; + expect(second.initialPage).toBe(3); + expect(second.pageSize).toBe(3); + expect(second.someFilter).toBe('A'); + expect(second.type).toBeUndefined(); + expect(second.userId).toBeUndefined(); + }); +}); + +describe('usePagesOrInfinite - infinite mode', () => { + it('aggregates pages, uses getKey offsets, and maps count to last page total_count', async () => { + const fetcher = vi.fn((p: any) => { + // return distinct pages based on initialPage + const pageNo = p.initialPage; + return Promise.resolve({ + data: [{ id: `p${pageNo}-a` }, { id: `p${pageNo}-b` }], + total_count: 9 + pageNo, // varying count, last page should be used + }); + }); + + const params = { initialPage: 1, pageSize: 2 } as const; + const config = { infinite: true, keepPreviousData: false, enabled: true } as const; + const cacheKeys = { type: 't-infinite', orgId: 'org_1' } as const; + + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + + // first render should fetch first page + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(fetcher).toHaveBeenCalledTimes(1); + const firstArgs = fetcher.mock.calls[0][0]; + expect(firstArgs).toStrictEqual({ initialPage: 1, pageSize: 2 }); + expect(firstArgs.type).toBeUndefined(); + expect(firstArgs.orgId).toBeUndefined(); + + // Data should include page 1 entries + expect(result.current.data).toEqual([{ id: 'p1-a' }, { id: 'p1-b' }]); + expect(result.current.page).toBe(1); + + // load next page (size -> 2) + act(() => { + result.current.fetchNext(); + }); + await waitFor(() => expect(result.current.page).toBe(2)); + await waitFor(() => expect(result.current.data.length).toBe(4)); + + // SWR may refetch the first page after size change; ensure both pages 1 and 2 were requested + expect(fetcher.mock.calls.length).toBeGreaterThanOrEqual(2); + const requestedPages = fetcher.mock.calls.map(c => c[0].initialPage); + expect(requestedPages).toContain(1); + expect(requestedPages).toContain(2); + + // flattened data of both pages + expect(result.current.data).toEqual([{ id: 'p1-a' }, { id: 'p1-b' }, { id: 'p2-a' }, { id: 'p2-b' }]); + + // count should be taken from the last page's total_count + expect(result.current.count).toBe(11); + + // setData should replace the aggregated pages + await act(async () => { + await (result.current as any).setData([{ data: [{ id: 'X' }], total_count: 1 }]); + }); + expect(result.current.data).toEqual([{ id: 'X' }]); + + // revalidate should not throw + await act(async () => { + await (result.current as any).revalidate(); + }); + }); +}); + +describe('usePagesOrInfinite - disabled and isSignedIn gating', () => { + it('does not fetch when enabled=false (pagination mode)', () => { + const fetcher = vi.fn(async () => ({ data: [], total_count: 0 })); + + const params = { initialPage: 1, pageSize: 3 } as const; + const config = { infinite: false, enabled: false } as const; + const cacheKeys = { type: 't-disabled' } as const; + + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + + // our SWR mock sets loading=false if key is null and not calling fetcher + expect(fetcher).toHaveBeenCalledTimes(0); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toEqual([]); + expect(result.current.count).toBe(0); + }); + + it('does not fetch when isSignedIn=false (pagination mode)', () => { + const fetcher = vi.fn(async () => ({ data: [], total_count: 0 })); + + const params = { initialPage: 1, pageSize: 3 } as const; + const config = { infinite: false, enabled: true, isSignedIn: false } as const; + const cacheKeys = { type: 't-signedin-false' } as const; + + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + + expect(fetcher).toHaveBeenCalledTimes(0); + expect(result.current.data).toEqual([]); + expect(result.current.count).toBe(0); + }); + + it('does not fetch when isSignedIn=false (infinite mode)', async () => { + const fetcher = vi.fn(async () => ({ data: [], total_count: 0 })); + + const params = { initialPage: 1, pageSize: 3 } as const; + const config = { infinite: true, enabled: true, isSignedIn: false } as const; + const cacheKeys = { type: 't-signedin-false-inf' } as const; + + const { result } = renderHook(() => usePagesOrInfinite(params, fetcher, config, cacheKeys), { wrapper }); + + expect(fetcher).toHaveBeenCalledTimes(0); + expect(result.current.data).toEqual([]); + expect(result.current.count).toBe(0); + }); +}); + +describe('usePagesOrInfinite - cache mode', () => { + it('does not call fetcher in cache mode and allows local setData/revalidate', async () => { + const fetcher = vi.fn(async () => ({ data: [{ id: 'remote' }], total_count: 10 })); + + const params = { initialPage: 1, pageSize: 3 } as const; + const config = { infinite: false, enabled: true, __experimental_mode: 'cache' as const }; + const cacheKeys = { type: 't-cache', userId: 'u1' } as const; + + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + + // Should never be fetching in cache mode + expect(result.current.isFetching).toBe(false); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + // Should not have called fetcher + expect(fetcher).toHaveBeenCalledTimes(0); + + await act(async () => { + await (result.current as any).setData({ data: [{ id: 'cached' }], total_count: 1 }); + }); + + expect(result.current.data).toEqual([{ id: 'cached' }]); + expect(result.current.count).toBe(1); + + await act(async () => { + await (result.current as any).revalidate(); + }); + }); +}); + +describe('usePagesOrInfinite - keepPreviousData behavior', () => { + it('keeps previous page data while fetching next page (pagination mode)', async () => { + const deferred = createDeferredPromise(); + const fetcher = vi.fn(async (p: any) => { + if (p.initialPage === 1) { + return { data: [{ id: 'p1-a' }, { id: 'p1-b' }], total_count: 4 }; + } + return deferred.promise.then(() => ({ data: [{ id: 'p2-a' }, { id: 'p2-b' }], total_count: 4 })); + }); + + const params = { initialPage: 1, pageSize: 2 } as const; + const config = { infinite: false, keepPreviousData: true, enabled: true } as const; + const cacheKeys = { type: 't-keepPrev' } as const; + + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual([{ id: 'p1-a' }, { id: 'p1-b' }]); + + act(() => { + result.current.fetchNext(); + }); + // page updated immediately, data remains previous while fetching + expect(result.current.page).toBe(2); + expect(result.current.isFetching).toBe(true); + expect(result.current.data).toEqual([{ id: 'p1-a' }, { id: 'p1-b' }]); + + // resolve next page + deferred.resolve(undefined); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + expect(result.current.data).toEqual([{ id: 'p2-a' }, { id: 'p2-b' }]); + }); +}); + +describe('usePagesOrInfinite - pagination helpers', () => { + it('computes pageCount/hasNext/hasPrevious correctly for initialPage>1', async () => { + const totalCount = 37; + const fetcher = vi.fn(async (p: any) => ({ + data: Array.from({ length: p.pageSize }, (_, i) => ({ id: i })), + total_count: totalCount, + })); + + const params = { initialPage: 3, pageSize: 5 } as const; + const config = { infinite: false, enabled: true } as const; + const cacheKeys = { type: 't-helpers' } as const; + + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + // offset = (3-1)*5 = 10; remaining = 37-10 = 27; pageCount = ceil(27/5) = 6 + expect(result.current.pageCount).toBe(6); + + act(() => { + result.current.fetchPrevious(); + }); + expect(result.current.page).toBe(2); + + act(() => { + result.current.fetchNext(); + result.current.fetchNext(); + }); + expect(result.current.page).toBe(4); + }); + + it('in infinite mode, page reflects size and hasNext/hasPrevious respond to size', async () => { + const totalCount = 12; + const fetcher = vi.fn(async (p: any) => ({ + data: Array.from({ length: p.pageSize }, (_, i) => ({ id: i })), + total_count: totalCount, + })); + + const params = { initialPage: 1, pageSize: 4 } as const; + const config = { infinite: true, enabled: true } as const; + const cacheKeys = { type: 't-infinite-page' } as const; + + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + // size starts at 1 + expect(result.current.page).toBe(1); + + act(() => { + result.current.fetchNext(); // size -> 2 + }); + await waitFor(() => expect(result.current.page).toBe(2)); + + expect(result.current.page).toBe(2); + }); +}); + +describe('usePagesOrInfinite - behaviors mirrored from useCoreOrganization', () => { + it('pagination mode: initial loading/fetching true, hasNextPage toggles, data replaced on next page (Promise-based fetcher)', async () => { + const fetcher = vi.fn(async (p: any) => { + if (p.initialPage === 1) { + return Promise.resolve({ + data: [{ id: '1' }, { id: '2' }], + total_count: 4, + }); + } + return Promise.resolve({ + data: [{ id: '3' }, { id: '4' }], + total_count: 4, + }); + }); + + const params = { initialPage: 1, pageSize: 2 } as const; + const config = { infinite: false, keepPreviousData: false, enabled: true } as const; + const cacheKeys = { type: 't-core-like-paginated' } as const; + + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + + // initial + expect(result.current.isLoading).toBe(true); + expect(result.current.isFetching).toBe(true); + expect(result.current.count).toBe(0); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.count).toBe(4); + expect(result.current.page).toBe(1); + expect(result.current.hasNextPage).toBe(true); + expect(result.current.data).toEqual([{ id: '1' }, { id: '2' }]); + + // trigger next page and assert loading toggles + act(() => { + result.current.fetchNext(); + }); + await waitFor(() => expect(result.current.isLoading).toBe(true)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.page).toBe(2); + expect(result.current.hasNextPage).toBe(false); + expect(result.current.data).toEqual([{ id: '3' }, { id: '4' }]); + }); + + it('infinite mode: isFetching toggles on fetchNext while isLoading stays false after first page', async () => { + const deferred = createDeferredPromise(); + const fetcher = vi.fn(async (p: any) => { + if (p.initialPage === 1) { + return { + data: [{ id: '1' }, { id: '2' }], + total_count: 4, + }; + } + return deferred.promise.then(() => ({ data: [{ id: '3' }, { id: '4' }], total_count: 4 })); + }); + + const params = { initialPage: 1, pageSize: 2 } as const; + const config = { infinite: true, keepPreviousData: false, enabled: true } as const; + const cacheKeys = { type: 't-core-like-infinite' } as const; + + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.isFetching).toBe(false); + expect(result.current.data).toEqual([{ id: '1' }, { id: '2' }]); + + act(() => { + result.current.fetchNext(); + }); + // after first page loaded, next loads should not set isLoading, only isFetching + expect(result.current.isLoading).toBe(false); + await waitFor(() => expect(result.current.isFetching).toBe(true)); + + deferred.resolve(undefined); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + expect(result.current.data).toEqual([{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]); + }); +}); + +describe('usePagesOrInfinite - revalidate behavior', () => { + it('pagination mode: isFetching toggles during revalidate, isLoading stays false after initial load', async () => { + const deferred = createDeferredPromise(); + let callCount = 0; + const fetcher = vi.fn(async (_p: any) => { + callCount++; + if (callCount === 1) { + return { data: [{ id: 'initial-1' }, { id: 'initial-2' }], total_count: 4 }; + } + return deferred.promise.then(() => ({ + data: [{ id: 'revalidated-1' }, { id: 'revalidated-2' }], + total_count: 4, + })); + }); + + const params = { initialPage: 1, pageSize: 2 } as const; + const config = { infinite: false, enabled: true } as const; + const cacheKeys = { type: 't-revalidate-paginated' } as const; + + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + + // Wait for initial load + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.isFetching).toBe(false); + expect(result.current.data).toEqual([{ id: 'initial-1' }, { id: 'initial-2' }]); + + // Trigger revalidate + act(() => { + (result.current as any).revalidate(); + }); + + // isFetching should become true, but isLoading should stay false after initial load + await waitFor(() => expect(result.current.isFetching).toBe(true)); + expect(result.current.isLoading).toBe(false); + + // Resolve the revalidation + deferred.resolve(undefined); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + // Data should be updated + expect(result.current.data).toEqual([{ id: 'revalidated-1' }, { id: 'revalidated-2' }]); + expect(result.current.isLoading).toBe(false); + }); + + it('infinite mode: isFetching toggles during revalidate, isLoading stays false after initial load', async () => { + const deferred = createDeferredPromise(); + let callCount = 0; + const fetcher = vi.fn(async (_p: any) => { + callCount++; + if (callCount === 1) { + return { data: [{ id: 'initial-1' }, { id: 'initial-2' }], total_count: 4 }; + } + return deferred.promise.then(() => ({ + data: [{ id: 'revalidated-1' }, { id: 'revalidated-2' }], + total_count: 4, + })); + }); + + const params = { initialPage: 1, pageSize: 2 } as const; + const config = { infinite: true, enabled: true } as const; + const cacheKeys = { type: 't-revalidate-infinite' } as const; + + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + + // Wait for initial load + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.isFetching).toBe(false); + expect(result.current.data).toEqual([{ id: 'initial-1' }, { id: 'initial-2' }]); + + // Trigger revalidate + act(() => { + (result.current as any).revalidate(); + }); + + // isFetching should become true, but isLoading should stay false after initial load + await waitFor(() => expect(result.current.isFetching).toBe(true)); + expect(result.current.isLoading).toBe(false); + + // Resolve the revalidation + deferred.resolve(undefined); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + // Data should be updated + expect(result.current.data).toEqual([{ id: 'revalidated-1' }, { id: 'revalidated-2' }]); + expect(result.current.isLoading).toBe(false); + }); + + it('infinite mode: revalidate refetches all previously loaded pages', async () => { + const fetcherCalls: Array<{ page: number; timestamp: string }> = []; + const fetcher = vi.fn(async (p: any) => { + const callTime = fetcherCalls.length < 2 ? 'initial' : 'revalidate'; + fetcherCalls.push({ page: p.initialPage, timestamp: callTime }); + + return { + data: Array.from({ length: p.pageSize }, (_, i) => ({ + id: `p${p.initialPage}-${i}-${callTime}`, + })), + total_count: 8, + }; + }); + + const params = { initialPage: 1, pageSize: 2 } as const; + const config = { infinite: true, enabled: true } as const; + const cacheKeys = { type: 't-revalidate-all-pages' } as const; + + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + + // Wait for initial page load + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data.length).toBe(2); + expect(result.current.page).toBe(1); + + // Load second page + act(() => { + result.current.fetchNext(); + }); + await waitFor(() => expect(result.current.page).toBe(2)); + await waitFor(() => expect(result.current.data.length).toBe(4)); + + // At this point, we should have 2 initial fetcher calls (page 1 and page 2) + const initialCallCount = fetcherCalls.filter(c => c.timestamp === 'initial').length; + expect(initialCallCount).toBeGreaterThanOrEqual(2); + + // Clear the array to track revalidation calls + const callCountBeforeRevalidate = fetcherCalls.length; + + // Trigger revalidate + await act(async () => { + await (result.current as any).revalidate(); + }); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + // After revalidate, we should have additional calls for both pages + const revalidateCalls = fetcherCalls.slice(callCountBeforeRevalidate); + expect(revalidateCalls.length).toBeGreaterThanOrEqual(2); + + // Verify both pages were revalidated (SWR refetches all pages in infinite mode) + const revalidatedPages = revalidateCalls.map(c => c.page); + expect(revalidatedPages).toContain(1); + expect(revalidatedPages).toContain(2); + + // Data should reflect revalidated content + expect(result.current.data).toEqual([ + { id: 'p1-0-revalidate' }, + { id: 'p1-1-revalidate' }, + { id: 'p2-0-revalidate' }, + { id: 'p2-1-revalidate' }, + ]); + }); +}); + +describe('usePagesOrInfinite - error propagation', () => { + it('sets error and isError in pagination mode when fetcher throws', async () => { + const fetcher = vi.fn(async () => { + throw new Error('boom'); + }); + + const params = { initialPage: 1, pageSize: 2 } as const; + const config = { infinite: false, enabled: true } as const; + const cacheKeys = { type: 't-error' } as const; + + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.isLoading).toBe(false); + }); + + it('sets error and isError in infinite mode when fetcher throws', async () => { + const fetcher = vi.fn(() => Promise.reject(new Error('boom2'))); + + const params = { initialPage: 1, pageSize: 2 } as const; + const config = { infinite: true, enabled: true } as const; + const cacheKeys = { type: 't-error-inf' } as const; + + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.isError).toBe(true); + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx b/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx new file mode 100644 index 00000000000..926b7cb6c3d --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx @@ -0,0 +1,115 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockUser: any = { id: 'user_1' }; +const mockOrganization: any = { id: 'org_1' }; + +const getPlansSpy = vi.fn((args: any) => + Promise.resolve({ + // pageSize maps to limit; default to 10 if missing + data: Array.from({ length: args.limit ?? args.pageSize ?? 10 }, (_, i) => ({ id: `plan_${i + 1}` })), + total_count: 25, + }), +); + +const mockClerk = { + loaded: true, + billing: { + getPlans: getPlansSpy, + }, + telemetry: { record: vi.fn() }, + __unstable__environment: { + commerceSettings: { + billing: { + user: { enabled: true }, + organization: { enabled: true }, + }, + }, + }, +}; + +vi.mock('../../contexts', () => { + return { + useAssertWrappedByClerkProvider: () => {}, + useClerkInstanceContext: () => mockClerk, + useUserContext: () => (mockClerk.loaded ? mockUser : null), + useOrganizationContext: () => ({ organization: mockClerk.loaded ? mockOrganization : null }), + }; +}); + +import { usePlans } from '../usePlans'; +import { wrapper } from './wrapper'; + +describe('usePlans', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockClerk.loaded = true; + mockClerk.__unstable__environment.commerceSettings.billing.user.enabled = true; + mockClerk.__unstable__environment.commerceSettings.billing.organization.enabled = true; + }); + + it('does not call fetcher when clerk.loaded is false', () => { + mockClerk.loaded = false; + const { result } = renderHook(() => usePlans({ initialPage: 1, pageSize: 5 }), { wrapper }); + + expect(getPlansSpy).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toEqual([]); + expect(result.current.count).toBe(0); + }); + + it('fetches plans for user when loaded', async () => { + const { result } = renderHook(() => usePlans({ initialPage: 1, pageSize: 5 }), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(getPlansSpy).toHaveBeenCalledTimes(1); + // ensure correct args passed: for: 'user' and limit/page (rest) + expect(getPlansSpy.mock.calls[0][0]).toStrictEqual({ for: 'user', initialPage: 1, pageSize: 5 }); + expect(result.current.data.length).toBe(5); + expect(result.current.count).toBe(25); + }); + + it('fetches plans for organization when for=organization', async () => { + const { result } = renderHook(() => usePlans({ initialPage: 1, pageSize: 5, for: 'organization' } as any), { + wrapper, + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(getPlansSpy).toHaveBeenCalledTimes(1); + // orgId must not leak to fetcher + expect(getPlansSpy.mock.calls[0][0]).toStrictEqual({ for: 'organization', initialPage: 1, pageSize: 5 }); + expect(result.current.data.length).toBe(5); + }); + + it('fetches plans without a user (unauthenticated allowed)', async () => { + // simulate no user + mockUser.id = undefined; + + const { result } = renderHook(() => usePlans({ initialPage: 1, pageSize: 4 }), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(true)); + + expect(getPlansSpy).toHaveBeenCalledTimes(1); + expect(getPlansSpy.mock.calls[0][0]).toStrictEqual({ for: 'user', pageSize: 4, initialPage: 1 }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data.length).toBe(4); + }); + + it('fetches organization plans even when organization id is missing', async () => { + // simulate no organization id + mockOrganization.id = undefined; + + const { result } = renderHook(() => usePlans({ initialPage: 1, pageSize: 3, for: 'organization' } as any), { + wrapper, + }); + + await waitFor(() => expect(result.current.isLoading).toBe(true)); + + expect(getPlansSpy).toHaveBeenCalledTimes(1); + // orgId must not leak to fetcher + expect(getPlansSpy.mock.calls[0][0]).toStrictEqual({ for: 'organization', pageSize: 3, initialPage: 1 }); + expect(result.current.data.length).toBe(3); + }); +}); diff --git a/packages/shared/src/react/hooks/__tests__/usePreviousValue.spec.ts b/packages/shared/src/react/hooks/__tests__/usePreviousValue.spec.ts new file mode 100644 index 00000000000..f075bcb6751 --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/usePreviousValue.spec.ts @@ -0,0 +1,53 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { usePreviousValue } from '../usePreviousValue'; + +describe('usePreviousValue', () => { + it('returns null on first render', () => { + const { result } = renderHook(() => usePreviousValue('A')); + expect(result.current).toBeNull(); + }); + + it('tracks previous value for strings', () => { + const { result, rerender } = renderHook((v: string) => usePreviousValue(v), { initialProps: 'A' }); + expect(result.current).toBeNull(); + rerender('B'); + expect(result.current).toBe('A'); + rerender('C'); + expect(result.current).toBe('B'); + }); + + it('tracks previous value for numbers', () => { + const { result, rerender } = renderHook((v: number) => usePreviousValue(v), { initialProps: 1 }); + expect(result.current).toBeNull(); + rerender(2); + expect(result.current).toBe(1); + rerender(2); + expect(result.current).toBe(1); + rerender(3); + expect(result.current).toBe(2); + }); + + it('tracks previous value for booleans', () => { + const { result, rerender } = renderHook((v: boolean) => usePreviousValue(v), { initialProps: false }); + expect(result.current).toBeNull(); + rerender(true); + expect(result.current).toBe(false); + rerender(false); + expect(result.current).toBe(true); + }); + + it('tracks previous value for null and undefined', () => { + const { result, rerender } = renderHook(v => usePreviousValue(v), { + initialProps: null, + }); + expect(result.current).toBeNull(); + rerender(undefined); + expect(result.current).toBeNull(); + rerender('x'); + expect(result.current).toBeUndefined(); + rerender(null); + expect(result.current).toBe('x'); + }); +}); diff --git a/packages/shared/src/react/hooks/__tests__/useSafeValues.spec.ts b/packages/shared/src/react/hooks/__tests__/useSafeValues.spec.ts new file mode 100644 index 00000000000..bdd6c29d6be --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/useSafeValues.spec.ts @@ -0,0 +1,61 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { useWithSafeValues } from '../usePagesOrInfinite'; + +describe('useWithSafeValues', () => { + it('returns defaults when params is true or undefined and caches page/pageSize', () => { + const defaults = { initialPage: 1, pageSize: 10, infinite: false, keepPreviousData: false } as const; + + // params=true -> use defaults + const { result: r1 } = renderHook(() => useWithSafeValues(true, defaults)); + expect(r1.current).toStrictEqual(defaults); + + // params=undefined -> defaults + const { result: r2 } = renderHook(() => useWithSafeValues(undefined, defaults)); + expect(r2.current).toStrictEqual(defaults); + + // params with overrides; ensure initial refs are cached across re-renders + const { result: r3, rerender } = renderHook( + ({ page }) => + useWithSafeValues({ initialPage: page, pageSize: 5, infinite: true, keepPreviousData: true }, defaults as any), + { initialProps: { page: 2 } }, + ); + + expect(r3.current.initialPage).toBe(2); + expect(r3.current.pageSize).toBe(5); + + // change prop; cached initialPage/pageSize should not change + rerender({ page: 3 }); + expect(r3.current.initialPage).toBe(2); + expect(r3.current.pageSize).toBe(5); + }); + + it('returns user-provided options over defaults (per JSDoc example)', () => { + const defaults = { initialPage: 1, pageSize: 10, infinite: false, keepPreviousData: false } as const; + const user = { initialPage: 2, pageSize: 20, infinite: true } as const; + + const { result } = renderHook(() => useWithSafeValues(user as any, defaults as any)); + + expect(result.current).toStrictEqual({ + initialPage: 2, + pageSize: 20, + infinite: true, + keepPreviousData: false, + }); + }); + + it('merges unspecified keys from defaults when options object omits them', () => { + const defaults = { initialPage: 1, pageSize: 10, infinite: false, keepPreviousData: true } as const; + const user = { pageSize: 50 } as const; + + const { result } = renderHook(() => useWithSafeValues(user as any, defaults as any)); + + expect(result.current).toStrictEqual({ + initialPage: 1, + pageSize: 50, + infinite: false, + keepPreviousData: true, + }); + }); +}); diff --git a/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx b/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx new file mode 100644 index 00000000000..9907cfe4e09 --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx @@ -0,0 +1,132 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useSubscription } from '../useSubscription'; +import { wrapper } from './wrapper'; + +// Dynamic mock state for contexts +let mockUser: any = { id: 'user_1' }; +let mockOrganization: any = { id: 'org_1' }; +let userBillingEnabled = true; +let orgBillingEnabled = true; + +// Prepare mock clerk with billing.getSubscription behavior +const getSubscriptionSpy = vi.fn((args?: { orgId?: string }) => + Promise.resolve({ id: args?.orgId ? `sub_org_${args.orgId}` : 'sub_user_user_1' }), +); + +const mockClerk = { + loaded: true, + billing: { + getSubscription: getSubscriptionSpy, + }, + telemetry: { record: vi.fn() }, + __unstable__environment: { + commerceSettings: { + billing: { + user: { enabled: userBillingEnabled }, + organization: { enabled: orgBillingEnabled }, + }, + }, + }, +}; + +vi.mock('../../contexts', () => { + return { + useAssertWrappedByClerkProvider: () => {}, + useClerkInstanceContext: () => mockClerk, + useUserContext: () => (mockClerk.loaded ? mockUser : null), + useOrganizationContext: () => ({ organization: mockClerk.loaded ? mockOrganization : null }), + }; +}); + +describe('useSubscription', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset environment flags and state + userBillingEnabled = true; + orgBillingEnabled = true; + mockUser = { id: 'user_1' }; + mockOrganization = { id: 'org_1' }; + mockClerk.__unstable__environment.commerceSettings.billing.user.enabled = userBillingEnabled; + mockClerk.__unstable__environment.commerceSettings.billing.organization.enabled = orgBillingEnabled; + }); + + it('does not fetch when billing disabled for user', () => { + mockClerk.__unstable__environment.commerceSettings.billing.user.enabled = false; + + const { result } = renderHook(() => useSubscription(), { wrapper }); + + expect(getSubscriptionSpy).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.isFetching).toBe(false); + expect(result.current.data).toBeUndefined(); + }); + + it('fetches user subscription when billing enabled (no org)', async () => { + const { result } = renderHook(() => useSubscription(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(getSubscriptionSpy).toHaveBeenCalledTimes(1); + expect(getSubscriptionSpy).toHaveBeenCalledWith({}); + expect(result.current.data).toEqual({ id: 'sub_user_user_1' }); + }); + + it('fetches organization subscription when for=organization', async () => { + const { result } = renderHook(() => useSubscription({ for: 'organization' }), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(getSubscriptionSpy).toHaveBeenCalledTimes(1); + expect(getSubscriptionSpy).toHaveBeenCalledWith({ orgId: 'org_1' }); + expect(result.current.data).toEqual({ id: 'sub_org_org_1' }); + }); + + it('hides stale data on sign-out', async () => { + const { result, rerender } = renderHook(() => useSubscription({ for: 'organization' }), { + wrapper, + }); + + await waitFor(() => expect(result.current.isLoading).toBe(true)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual({ id: 'sub_org_org_1' }); + expect(getSubscriptionSpy).toHaveBeenCalledTimes(1); + + // Simulate sign-out + mockUser = null; + rerender(); + + // Asser that SWR will flip to fetching because the fetcherFN runs, but it forces `null` when userId is falsy. + await waitFor(() => expect(result.current.isFetching).toBe(true)); + + // The fetcher returns null when userId is falsy, so data should become null + await waitFor(() => expect(result.current.data).toBeNull()); + expect(getSubscriptionSpy).toHaveBeenCalledTimes(1); + expect(result.current.isFetching).toBe(false); + }); + + it('hides stale data on sign-out even with keepPreviousData=true', async () => { + const { result, rerender } = renderHook(({ kp }) => useSubscription({ keepPreviousData: kp }), { + wrapper, + initialProps: { kp: true }, + }); + + await waitFor(() => expect(result.current.isLoading).toBe(true)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual({ id: 'sub_user_user_1' }); + expect(getSubscriptionSpy).toHaveBeenCalledTimes(1); + + // Simulate sign-out + mockUser = null; + rerender({ kp: true }); + + // Asser that SWR will flip to fetching because the fetcherFN runs, but it forces `null` when userId is falsy. + await waitFor(() => expect(result.current.isFetching).toBe(true)); + + // The fetcher returns null when userId is falsy, so data should become null + await waitFor(() => expect(result.current.data).toBeNull()); + expect(getSubscriptionSpy).toHaveBeenCalledTimes(1); + expect(result.current.isFetching).toBe(false); + }); +}); diff --git a/packages/shared/src/react/hooks/__tests__/wrapper.tsx b/packages/shared/src/react/hooks/__tests__/wrapper.tsx new file mode 100644 index 00000000000..8ee95636f06 --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/wrapper.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { SWRConfig } from 'swr'; + +export const wrapper = ({ children }: { children: React.ReactNode }) => ( + new Map(), + }} + > + {children} + +); diff --git a/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx b/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx index 9d9bb6c2b28..58b3e1ba1be 100644 --- a/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx +++ b/packages/shared/src/react/hooks/createBillingPaginatedHook.tsx @@ -80,7 +80,7 @@ export function createBillingPaginatedHook(params: T | const newObj: Record = {}; for (const key of Object.keys(defaultValues)) { - // @ts-ignore + // @ts-ignore - defaultValues and params share shape; dynamic index access is safe here newObj[key] = shouldUseDefaults ? defaultValues[key] : (params?.[key] ?? defaultValues[key]); } @@ -157,10 +158,35 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, pageSize: pageSizeRef.current, }; + const previousIsSignedIn = usePreviousValue(isSignedIn); + // cacheMode being `true` indicates that the cache key is defined, but the fetcher is not. // This allows to ready the cache instead of firing a request. const shouldFetch = !triggerInfinite && enabled && (!cacheMode ? !!fetcher : true); - const swrKey = isSignedIn ? pagesCacheKey : shouldFetch ? pagesCacheKey : null; + + // Attention: + // + // This complex logic is necessary to ensure that the cached data is not used when the user is signed out. + // `useSWR` with `key` set to `null` and `keepPreviousData` set to `true` will return the previous cached data until the hook unmounts. + // So for hooks that render authenticated data, we need to ensure that the cached data is not used when the user is signed out. + // + // 1. Fetcher should not fire if user is signed out on mount. (fetcher does not run, loading states are not triggered) + // 2. If user was signed in and then signed out, cached data should become null. (fetcher runs and returns null, loading states are triggered) + // + // We achieve (2) by setting the key to the cache key when the user transitions to signed out and forcing the fetcher to return null. + const swrKey = + typeof isSignedIn === 'boolean' + ? previousIsSignedIn === true && isSignedIn === false + ? pagesCacheKey + : isSignedIn + ? shouldFetch + ? pagesCacheKey + : null + : null + : shouldFetch + ? pagesCacheKey + : null; + const swrFetcher = !cacheMode && !!fetcher ? (cacheKeyParams: Record) => { @@ -180,6 +206,22 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, mutate: swrMutate, } = useSWR(swrKey, swrFetcher, { keepPreviousData, ...cachingSWROptions }); + // Attention: + // + // Cache behavior for infinite loading when signing out: + // + // Unlike `useSWR` above (which requires complex transition handling), `useSWRInfinite` has simpler sign-out semantics: + // 1. When user is signed out on mount, the key getter returns `null`, preventing any fetches. + // 2. When user transitions from signed in to signed out, the key getter returns `null` for all page indices. + // 3. When `useSWRInfinite`'s key getter returns `null`, SWR will not fetch data and considers that page invalid. + // 4. Unlike paginated mode, `useSWRInfinite` does not support `keepPreviousData`, so there's no previous data retention. + // + // This simpler behavior works because: + // - `useSWRInfinite` manages multiple pages internally, each with its own cache key + // - When the key getter returns `null`, all page fetches are prevented and pages become invalid + // - Without `keepPreviousData`, the hook will naturally reflect the empty/invalid state + // + // Result: No special transition logic needed - just return `null` from key getter when `isSignedIn === false`. const { data: swrInfiniteData, isLoading: swrInfiniteIsLoading, @@ -190,7 +232,7 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, mutate: swrInfiniteMutate, } = useSWRInfinite( pageIndex => { - if (!triggerInfinite || !enabled) { + if (!triggerInfinite || !enabled || isSignedIn === false) { return null; } @@ -202,9 +244,9 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, }; }, cacheKeyParams => { - // @ts-ignore + // @ts-ignore - remove cache-only keys from request params const requestParams = getDifferentKeys(cacheKeyParams, cacheKeys); - // @ts-ignore + // @ts-ignore - fetcher expects Params subset; narrowing at call-site return fetcher?.(requestParams); }, cachingSWROptions, @@ -225,7 +267,7 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, } return setPaginatedPage(numberOrgFn); }, - [setSize], + [setSize, triggerInfinite], ); const data = useMemo(() => { diff --git a/packages/shared/src/react/hooks/usePlans.tsx b/packages/shared/src/react/hooks/usePlans.tsx index 77a45213908..25ed68e6424 100644 --- a/packages/shared/src/react/hooks/usePlans.tsx +++ b/packages/shared/src/react/hooks/usePlans.tsx @@ -14,10 +14,7 @@ export const usePlans = createBillingPaginatedHook { - // Cleanup `orgId` from the params - return clerk.billing.getPlans({ ...rest, for: _for }); - }; + return params => clerk.billing.getPlans({ ...params, for: _for }); }, options: { unauthenticated: true, diff --git a/packages/shared/src/react/hooks/usePreviousValue.ts b/packages/shared/src/react/hooks/usePreviousValue.ts new file mode 100644 index 00000000000..2957da192a8 --- /dev/null +++ b/packages/shared/src/react/hooks/usePreviousValue.ts @@ -0,0 +1,30 @@ +import { useRef } from 'react'; + +type Primitive = string | number | boolean | bigint | symbol | null | undefined; + +/** + * A hook that retains the previous value of a primitive type. + * It uses a ref to prevent causing unnecessary re-renders. + * + * @internal + * + * @example + * ``` + * Render 1: value = 'A' → returns null + * Render 2: value = 'B' → returns 'A' + * Render 3: value = 'B' → returns 'A' + * Render 4: value = 'B' → returns 'A' + * Render 5: value = 'C' → returns 'B' + * ``` + */ +export function usePreviousValue(value: T) { + const currentRef = useRef(value); + const previousRef = useRef(null); + + if (currentRef.current !== value) { + previousRef.current = currentRef.current; + currentRef.current = value; + } + + return previousRef.current; +}