From 9b5566e3119af5b56663cc1a7da3cfd469d26aba Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 14 Oct 2025 20:05:23 +0300 Subject: [PATCH 01/16] wip --- packages/shared/package.json | 3 +- .../__tests__/usePagesOrInfinite.spec.ts | 359 ++++++++++++++++++ 2 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts diff --git a/packages/shared/package.json b/packages/shared/package.json index 653bfc7b329..6b6bf2da8da 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -151,7 +151,8 @@ "test": "jest && vitest", "test:cache:clear": "jest --clearCache --useStderr", "test:ci": "jest --maxWorkers=70%", - "test:coverage": "jest --collectCoverage && open coverage/lcov-report/index.html" + "test:coverage": "jest --collectCoverage && open coverage/lcov-report/index.html", + "test:vitest": "vitest" }, "dependencies": { "@clerk/types": "workspace:^", 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..d2643d9117a --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts @@ -0,0 +1,359 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createDeferredPromise } from '../../../utils/createDeferredPromise'; +import { usePagesOrInfinite, useWithSafeValues } from '../usePagesOrInfinite'; + +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), + ); + + // 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 - infinite mode', () => { + it('aggregates pages, uses getKey offsets, and maps count to last page total_count', async () => { + const fetcher = vi.fn(async (p: any) => { + // return distinct pages based on initialPage + const pageNo = p.initialPage; + return { + 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), + ); + + // 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).toMatchObject({ 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), + ); + + // 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), + ); + + 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), + ); + + 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), + ); + 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), + ); + + 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), + ); + + 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('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).toMatchObject(defaults); + + // params=undefined -> defaults + const { result: r2 } = renderHook(() => useWithSafeValues(undefined, defaults)); + expect(r2.current).toMatchObject(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); + }); +}); + +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), + ); + + 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(async () => { + throw 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), + ); + + 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); + }); +}); From a43015a1ca7484e0f37b5d15c3eb2d1328988eac Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 14 Oct 2025 20:26:26 +0300 Subject: [PATCH 02/16] wip 2 --- .../__tests__/usePagesOrInfinite.spec.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts index d2643d9117a..a87821180bf 100644 --- a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts +++ b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts @@ -289,6 +289,89 @@ describe('usePagesOrInfinite - pagination helpers', () => { }); }); +describe('usePagesOrInfinite - behaviors mirrored from useCoreOrganization', () => { + it('pagination mode: initial loading/fetching true, hasNextPage toggles, data replaced on next page', async () => { + const fetcher = vi.fn(async (p: any) => { + if (p.initialPage === 1) { + return { + data: [{ id: '1' }, { id: '2' }], + total_count: 4, + }; + } + return { + 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), + ); + + // 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), + ); + + 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('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; From 48e1f6d95f49c48a6ecf0a7a1d250f89308353cc Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 14 Oct 2025 20:36:52 +0300 Subject: [PATCH 03/16] wip 3 --- .../__tests__/usePagesOrInfinite.spec.ts | 68 +++++++++++++++---- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts index a87821180bf..77aed01c981 100644 --- a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts +++ b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts @@ -65,15 +65,57 @@ describe('usePagesOrInfinite - basic pagination', () => { }); }); +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), + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + // 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(async (p: any) => { + const fetcher = vi.fn((p: any) => { // return distinct pages based on initialPage const pageNo = p.initialPage; - return { + 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; @@ -89,7 +131,7 @@ describe('usePagesOrInfinite - infinite mode', () => { expect(fetcher).toHaveBeenCalledTimes(1); const firstArgs = fetcher.mock.calls[0][0]; - expect(firstArgs).toMatchObject({ initialPage: 1, pageSize: 2 }); + expect(firstArgs).toStrictEqual({ initialPage: 1, pageSize: 2 }); expect(firstArgs.type).toBeUndefined(); expect(firstArgs.orgId).toBeUndefined(); @@ -290,18 +332,18 @@ describe('usePagesOrInfinite - pagination helpers', () => { }); describe('usePagesOrInfinite - behaviors mirrored from useCoreOrganization', () => { - it('pagination mode: initial loading/fetching true, hasNextPage toggles, data replaced on next page', async () => { + 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 { + return Promise.resolve({ data: [{ id: '1' }, { id: '2' }], total_count: 4, - }; + }); } - return { + return Promise.resolve({ data: [{ id: '3' }, { id: '4' }], total_count: 4, - }; + }); }); const params = { initialPage: 1, pageSize: 2 } as const; @@ -378,11 +420,11 @@ describe('useWithSafeValues', () => { // params=true -> use defaults const { result: r1 } = renderHook(() => useWithSafeValues(true, defaults)); - expect(r1.current).toMatchObject(defaults); + expect(r1.current).toStrictEqual(defaults); // params=undefined -> defaults const { result: r2 } = renderHook(() => useWithSafeValues(undefined, defaults)); - expect(r2.current).toMatchObject(defaults); + expect(r2.current).toStrictEqual(defaults); // params with overrides; ensure initial refs are cached across re-renders const { result: r3, rerender } = renderHook( @@ -421,9 +463,7 @@ describe('usePagesOrInfinite - error propagation', () => { }); it('sets error and isError in infinite mode when fetcher throws', async () => { - const fetcher = vi.fn(async () => { - throw new Error('boom2'); - }); + 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; From 9705699af58ac25dd7c18e820f0fe63d02824f56 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 14 Oct 2025 20:43:46 +0300 Subject: [PATCH 04/16] wip 4 --- .../__tests__/usePagesOrInfinite.spec.ts | 31 +--------- .../hooks/__tests__/useSafeValues.spec.ts | 61 +++++++++++++++++++ 2 files changed, 62 insertions(+), 30 deletions(-) create mode 100644 packages/shared/src/react/hooks/__tests__/useSafeValues.spec.ts diff --git a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts index 77aed01c981..fe425d4fa12 100644 --- a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts +++ b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts @@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createDeferredPromise } from '../../../utils/createDeferredPromise'; -import { usePagesOrInfinite, useWithSafeValues } from '../usePagesOrInfinite'; +import { usePagesOrInfinite } from '../usePagesOrInfinite'; describe('usePagesOrInfinite - basic pagination', () => { beforeEach(() => { @@ -414,35 +414,6 @@ describe('usePagesOrInfinite - behaviors mirrored from useCoreOrganization', () }); }); -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); - }); -}); - describe('usePagesOrInfinite - error propagation', () => { it('sets error and isError in pagination mode when fetcher throws', async () => { const fetcher = vi.fn(async () => { 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, + }); + }); +}); From 6745b5db0a2a023cf96e408986879fe53380c9dc Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 14 Oct 2025 22:02:36 +0300 Subject: [PATCH 05/16] tests for createBillingPaginatedHook --- .../createBillingPaginatedHook.spec.ts | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.ts diff --git a/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.ts b/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.ts new file mode 100644 index 00000000000..40a056b9486 --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.ts @@ -0,0 +1,184 @@ +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'; + +// 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 useFetcherMock = vi.fn(() => { + return vi.fn(); +}); + +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()); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(useFetcherMock).toHaveBeenCalledWith('user'); + + const fetcher = useFetcherMock.mock.results[0].value; + expect(fetcher).toHaveBeenCalled(); + expect(fetcher.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 })); + + // useFetcher is invoked eagerly, but the returned function should not be called + expect(useFetcherMock).toHaveBeenCalledWith('user'); + + const fetcher = useFetcherMock.mock.results[0].value; + expect(fetcher).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: 5 })); + + expect(useFetcherMock).toHaveBeenCalledWith('user'); + + const fetcher = useFetcherMock.mock.results[0].value; + expect(fetcher).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + }); + + it('authenticated hook: does not fetch when user is null', () => { + mockUser = null; + + const { result } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 5 })); + + expect(useFetcherMock).toHaveBeenCalledWith('user'); + + const fetcher = useFetcherMock.mock.results[0].value; + expect(fetcher).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 })); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(useFetcherMock).toHaveBeenCalledWith('user'); + + const fetcher = useFetcherMock.mock.results[0].value; + expect(fetcher).toHaveBeenCalled(); + expect(useFetcherMock.mock.results[0].value.mock.calls[0][0]).toStrictEqual({ initialPage: 1, pageSize: 4 }); + }); + + 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 })); + + expect(useFetcherMock).toHaveBeenCalledWith('user'); + expect(useFetcherMock.mock.results[0].value).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: 5 })); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(useFetcherMock).toHaveBeenCalledWith('user'); + const fetcher = useFetcherMock.mock.results[0].value; + expect(fetcher.mock.calls[0][0]).toStrictEqual({ initialPage: 1, pageSize: 5 }); + }); + + it('when for=organization orgId should be forwarded to fetcher', async () => { + const { result } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 7, for: 'organization' } as any)); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(useFetcherMock).toHaveBeenCalledWith('organization'); + const fetcher = useFetcherMock.mock.results[0].value; + expect(fetcher.mock.calls[0][0]).toStrictEqual({ + initialPage: 1, + pageSize: 7, + orgId: 'org_1', + }); + }); + + it('does not fetch in organization mode when organization billing disabled', () => { + mockClerk.__unstable__environment.commerceSettings.billing.organization.enabled = false; + + const { result } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 5, for: 'organization' } as any)); + + expect(useFetcherMock).toHaveBeenCalledWith('organization'); + const fetcher = useFetcherMock.mock.results[0].value; + expect(fetcher).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + }); + + it('unauthenticated hook: does not fetch in organization mode when organization billing disabled', () => { + mockClerk.__unstable__environment.commerceSettings.billing.organization.enabled = false; + + const { result } = renderHook(() => useDummyUnauth({ initialPage: 1, pageSize: 5, for: 'organization' } as any)); + + expect(useFetcherMock).toHaveBeenCalledWith('organization'); + const fetcher = useFetcherMock.mock.results[0].value; + expect(fetcher).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + }); +}); From b3637f721e1274d19304d9ea95eb8fafaaf95371 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 14 Oct 2025 22:40:35 +0300 Subject: [PATCH 06/16] tests for `useSubscription` --- .../hooks/__tests__/useSubscription.spec.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 packages/shared/src/react/hooks/__tests__/useSubscription.spec.ts diff --git a/packages/shared/src/react/hooks/__tests__/useSubscription.spec.ts b/packages/shared/src/react/hooks/__tests__/useSubscription.spec.ts new file mode 100644 index 00000000000..6fad8e43c2c --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/useSubscription.spec.ts @@ -0,0 +1,101 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// 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 }), + }; +}); + +import { useSubscription } from '../useSubscription'; + +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()); + + 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()); + + 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' })); + + 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 even with keepPreviousData=true', async () => { + const { result, rerender } = renderHook(({ kp }) => useSubscription({ keepPreviousData: kp }), { + initialProps: { kp: true }, + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual({ id: 'sub_user_user_1' }); + + // Simulate sign-out + mockUser = null; + rerender({ kp: true }); + + // The fetcher returns null when userId is falsy, so data should become null + await waitFor(() => expect(result.current.data).toBeNull()); + expect(result.current.isFetching).toBe(false); + }); +}); From 77054d35183a0a6d72fdce6b5bc9941b387847e0 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 14 Oct 2025 22:40:45 +0300 Subject: [PATCH 07/16] tests for `usePlans` --- .../react/hooks/__tests__/usePlans.spec.ts | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 packages/shared/src/react/hooks/__tests__/usePlans.spec.ts diff --git a/packages/shared/src/react/hooks/__tests__/usePlans.spec.ts b/packages/shared/src/react/hooks/__tests__/usePlans.spec.ts new file mode 100644 index 00000000000..2cc07ebde96 --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/usePlans.spec.ts @@ -0,0 +1,113 @@ +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'; + +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 })); + + 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 })); + + 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]).toMatchObject({ for: 'user' }); + expect(getPlansSpy.mock.calls[0][0].orgId).toBeUndefined(); + 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)); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(getPlansSpy).toHaveBeenCalledTimes(1); + expect(getPlansSpy.mock.calls[0][0]).toMatchObject({ for: 'organization' }); + // orgId must not leak to fetcher + expect(getPlansSpy.mock.calls[0][0].orgId).toBeUndefined(); + 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 })); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(getPlansSpy).toHaveBeenCalledTimes(1); + expect(getPlansSpy.mock.calls[0][0]).toStrictEqual({ for: 'user', pageSize: 4, initialPage: 1 }); + expect(getPlansSpy.mock.calls[0][0].orgId).toBeUndefined(); + 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)); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(getPlansSpy).toHaveBeenCalledTimes(1); + expect(getPlansSpy.mock.calls[0][0]).toStrictEqual({ for: 'organization', pageSize: 3, initialPage: 1 }); + // orgId should not leak to fetcher + expect(getPlansSpy.mock.calls[0][0].orgId).toBeUndefined(); + expect(result.current.data.length).toBe(3); + }); +}); From 109bd8a514ef8c3a29481a9ca51fe9c7cc1d2258 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 14 Oct 2025 22:58:52 +0300 Subject: [PATCH 08/16] tests for `usePagesOrInfinite` --- .../__tests__/usePagesOrInfinite.spec.ts | 81 +++++++++++++++++++ .../src/react/hooks/usePagesOrInfinite.ts | 8 +- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts index fe425d4fa12..25f19a3804c 100644 --- a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts +++ b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts @@ -205,6 +205,20 @@ describe('usePagesOrInfinite - disabled and isSignedIn gating', () => { 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)); + + expect(fetcher).toHaveBeenCalledTimes(0); + expect(result.current.data).toEqual([]); + expect(result.current.count).toBe(0); + }); }); describe('usePagesOrInfinite - cache mode', () => { @@ -269,6 +283,73 @@ describe('usePagesOrInfinite - keepPreviousData behavior', () => { }); }); +describe('usePagesOrInfinite - sign-out hides previously loaded data', () => { + it('pagination mode: data is cleared when isSignedIn switches to false', async () => { + const fetcher = vi.fn((p: any) => + Promise.resolve({ + data: Array.from({ length: p.pageSize }, (_, i) => ({ id: `p${p.initialPage}-${i}` })), + total_count: 5, + }), + ); + + const params = { initialPage: 1, pageSize: 2 } as const; + const baseConfig = { infinite: false, enabled: true } as const; + const cacheKeys = { type: 't-signedout-paginated' } as const; + + const { result, rerender } = renderHook( + ({ signedIn }) => + usePagesOrInfinite( + params as any, + fetcher as any, + { ...baseConfig, isSignedIn: signedIn } as any, + cacheKeys as any, + ), + { initialProps: { signedIn: true } }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data.length).toBe(2); + + // simulate sign-out + rerender({ signedIn: false }); + // data should become empty + await waitFor(() => expect(result.current.data).toEqual([])); + expect(result.current.count).toBe(0); + }); + + it('infinite mode: data is cleared when isSignedIn switches to false', async () => { + const fetcher = vi.fn((p: any) => + Promise.resolve({ + data: Array.from({ length: p.pageSize }, (_, i) => ({ id: `p${p.initialPage}-${i}` })), + total_count: 10, + }), + ); + + const params = { initialPage: 1, pageSize: 2 } as const; + const baseConfig = { infinite: true, enabled: true } as const; + const cacheKeys = { type: 't-signedout-infinite' } as const; + + const { result, rerender } = renderHook( + ({ signedIn }) => + usePagesOrInfinite( + params as any, + fetcher as any, + { ...baseConfig, isSignedIn: signedIn } as any, + cacheKeys as any, + ), + { initialProps: { signedIn: true } }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data.length).toBe(2); + + // simulate sign-out + rerender({ signedIn: false }); + await waitFor(() => expect(result.current.data).toEqual([])); + expect(result.current.count).toBe(0); + }); +}); + describe('usePagesOrInfinite - pagination helpers', () => { it('computes pageCount/hasNext/hasPrevious correctly for initialPage>1', async () => { const totalCount = 37; diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.ts b/packages/shared/src/react/hooks/usePagesOrInfinite.ts index 93ce9d07f75..218f9bfa92a 100644 --- a/packages/shared/src/react/hooks/usePagesOrInfinite.ts +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.ts @@ -160,7 +160,8 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, // 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; + const swrKey = isSignedIn === false ? null : shouldFetch ? pagesCacheKey : null; + const swrFetcher = !cacheMode && !!fetcher ? (cacheKeyParams: Record) => { @@ -190,7 +191,7 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, mutate: swrInfiniteMutate, } = useSWRInfinite( pageIndex => { - if (!triggerInfinite || !enabled) { + if (!triggerInfinite || !enabled || isSignedIn === false) { return null; } @@ -202,6 +203,9 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, }; }, cacheKeyParams => { + if (isSignedIn === false) { + return null; + } // @ts-ignore const requestParams = getDifferentKeys(cacheKeyParams, cacheKeys); // @ts-ignore From 790e8caa9cb8b40a0782d2f57f6e153af22e1c1a Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 15 Oct 2025 13:48:37 +0300 Subject: [PATCH 09/16] fix issue with useSubscription when existing data and user signs out --- ...ption.spec.ts => useSubscription.spec.tsx} | 52 +++++++++++++++++-- .../src/react/hooks/usePagesOrInfinite.ts | 16 +++--- 2 files changed, 55 insertions(+), 13 deletions(-) rename packages/shared/src/react/hooks/__tests__/{useSubscription.spec.ts => useSubscription.spec.tsx} (66%) diff --git a/packages/shared/src/react/hooks/__tests__/useSubscription.spec.ts b/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx similarity index 66% rename from packages/shared/src/react/hooks/__tests__/useSubscription.spec.ts rename to packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx index 6fad8e43c2c..e3fbd15e9c2 100644 --- a/packages/shared/src/react/hooks/__tests__/useSubscription.spec.ts +++ b/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx @@ -1,6 +1,10 @@ import { renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { SWRConfig } from 'swr'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useSubscription } from '../useSubscription'; + // Dynamic mock state for contexts let mockUser: any = { id: 'user_1' }; let mockOrganization: any = { id: 'org_1' }; @@ -12,6 +16,16 @@ const getSubscriptionSpy = vi.fn((args?: { orgId?: string }) => Promise.resolve({ id: args?.orgId ? `sub_org_${args.orgId}` : 'sub_user_user_1' }), ); +const wrapper = ({ children }: { children: React.ReactNode }) => ( + new Map(), + }} + > + {children} + +); + const mockClerk = { loaded: true, billing: { @@ -37,8 +51,6 @@ vi.mock('../../contexts', () => { }; }); -import { useSubscription } from '../useSubscription'; - describe('useSubscription', () => { beforeEach(() => { vi.clearAllMocks(); @@ -54,7 +66,7 @@ describe('useSubscription', () => { it('does not fetch when billing disabled for user', () => { mockClerk.__unstable__environment.commerceSettings.billing.user.enabled = false; - const { result } = renderHook(() => useSubscription()); + const { result } = renderHook(() => useSubscription(), { wrapper }); expect(getSubscriptionSpy).not.toHaveBeenCalled(); expect(result.current.isLoading).toBe(false); @@ -63,7 +75,7 @@ describe('useSubscription', () => { }); it('fetches user subscription when billing enabled (no org)', async () => { - const { result } = renderHook(() => useSubscription()); + const { result } = renderHook(() => useSubscription(), { wrapper }); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -73,7 +85,7 @@ describe('useSubscription', () => { }); it('fetches organization subscription when for=organization', async () => { - const { result } = renderHook(() => useSubscription({ for: 'organization' })); + const { result } = renderHook(() => useSubscription({ for: 'organization' }), { wrapper }); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -82,20 +94,50 @@ describe('useSubscription', () => { 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/usePagesOrInfinite.ts b/packages/shared/src/react/hooks/usePagesOrInfinite.ts index 218f9bfa92a..cf50c1085df 100644 --- a/packages/shared/src/react/hooks/usePagesOrInfinite.ts +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.ts @@ -81,7 +81,7 @@ export const useWithSafeValues = (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]); } @@ -160,7 +160,7 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, // 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 === false ? null : shouldFetch ? pagesCacheKey : null; + const swrKey = shouldFetch ? pagesCacheKey : null; const swrFetcher = !cacheMode && !!fetcher @@ -191,7 +191,7 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, mutate: swrInfiniteMutate, } = useSWRInfinite( pageIndex => { - if (!triggerInfinite || !enabled || isSignedIn === false) { + if (!triggerInfinite || !enabled) { return null; } @@ -204,11 +204,11 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, }, cacheKeyParams => { if (isSignedIn === false) { - return null; + return undefined; } - // @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, @@ -229,12 +229,12 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, } return setPaginatedPage(numberOrgFn); }, - [setSize], + [setSize, triggerInfinite], ); const data = useMemo(() => { if (triggerInfinite) { - return swrInfiniteData?.map(a => a?.data).flat() ?? []; + return (swrInfiniteData ?? []).flatMap(page => page?.data ?? []); } return swrData?.data ?? []; }, [triggerInfinite, swrData, swrInfiniteData]); From 5bd4c4795c7c6b9649542c87da8da5ab02b35435 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 15 Oct 2025 14:48:05 +0300 Subject: [PATCH 10/16] extensive testing for usePagesOrInfinite --- package.json | 2 +- .../__tests__/usePagesOrInfinite.spec.ts | 176 +++++++++++++++++- .../src/react/hooks/usePagesOrInfinite.ts | 9 +- 3 files changed, 178 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 158d0ed4a27..2b499d24391 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "test:integration:ap-flows": "pnpm test:integration:base --grep @ap-flows", "test:integration:astro": "E2E_APP_ID=astro.* pnpm test:integration:base --grep @astro", "test:integration:base": "pnpm playwright test --config integration/playwright.config.ts", - "test:integration:billing": "E2E_APP_ID=withBilling.* pnpm test:integration:base --grep @billing", + "test:integration:billing": "E2E_APP_ID=withBilling.next.appRouter pnpm test:integration:base --grep @billing", "test:integration:cleanup": "pnpm playwright test --config integration/playwright.cleanup.config.ts", "test:integration:custom": "pnpm test:integration:base --grep @custom", "test:integration:deployment:nextjs": "pnpm playwright test --config integration/playwright.deployments.config.ts", diff --git a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts index 25f19a3804c..11c6d1ea24a 100644 --- a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts +++ b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts @@ -233,6 +233,10 @@ describe('usePagesOrInfinite - cache mode', () => { usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), ); + // 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 () => { @@ -307,14 +311,19 @@ describe('usePagesOrInfinite - sign-out hides previously loaded data', () => { { initialProps: { signedIn: true } }, ); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + 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 rerender({ signedIn: false }); // 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('infinite mode: data is cleared when isSignedIn switches to false', async () => { @@ -340,13 +349,18 @@ describe('usePagesOrInfinite - sign-out hides previously loaded data', () => { { initialProps: { signedIn: true } }, ); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + 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 rerender({ signedIn: false }); 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); }); }); @@ -495,6 +509,164 @@ describe('usePagesOrInfinite - behaviors mirrored from useCoreOrganization', () }); }); +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), + ); + + // 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), + ); + + // 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), + ); + + // 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 () => { diff --git a/packages/shared/src/react/hooks/usePagesOrInfinite.ts b/packages/shared/src/react/hooks/usePagesOrInfinite.ts index cf50c1085df..89d035237c0 100644 --- a/packages/shared/src/react/hooks/usePagesOrInfinite.ts +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.ts @@ -160,7 +160,7 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, // 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 = shouldFetch ? pagesCacheKey : null; + const swrKey = isSignedIn === false ? null : shouldFetch ? pagesCacheKey : null; const swrFetcher = !cacheMode && !!fetcher @@ -191,7 +191,7 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, mutate: swrInfiniteMutate, } = useSWRInfinite( pageIndex => { - if (!triggerInfinite || !enabled) { + if (!triggerInfinite || !enabled || isSignedIn === false) { return null; } @@ -203,9 +203,6 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, }; }, cacheKeyParams => { - if (isSignedIn === false) { - return undefined; - } // @ts-ignore - remove cache-only keys from request params const requestParams = getDifferentKeys(cacheKeyParams, cacheKeys); // @ts-ignore - fetcher expects Params subset; narrowing at call-site @@ -234,7 +231,7 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, const data = useMemo(() => { if (triggerInfinite) { - return (swrInfiniteData ?? []).flatMap(page => page?.data ?? []); + return swrInfiniteData?.map(a => a?.data).flat() ?? []; } return swrData?.data ?? []; }, [triggerInfinite, swrData, swrInfiniteData]); From c7facf7deb7d63545cc125c5c4b9d89df3cad9f9 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 15 Oct 2025 15:21:53 +0300 Subject: [PATCH 11/16] revert --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2b499d24391..158d0ed4a27 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "test:integration:ap-flows": "pnpm test:integration:base --grep @ap-flows", "test:integration:astro": "E2E_APP_ID=astro.* pnpm test:integration:base --grep @astro", "test:integration:base": "pnpm playwright test --config integration/playwright.config.ts", - "test:integration:billing": "E2E_APP_ID=withBilling.next.appRouter pnpm test:integration:base --grep @billing", + "test:integration:billing": "E2E_APP_ID=withBilling.* pnpm test:integration:base --grep @billing", "test:integration:cleanup": "pnpm playwright test --config integration/playwright.cleanup.config.ts", "test:integration:custom": "pnpm test:integration:base --grep @custom", "test:integration:deployment:nextjs": "pnpm playwright test --config integration/playwright.deployments.config.ts", From c825b8f59f8751c0b9cdadbe39de505d0df245a8 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 15 Oct 2025 15:34:30 +0300 Subject: [PATCH 12/16] add wrapper --- ...ts => createBillingPaginatedHook.spec.tsx} | 82 ++++++++--------- .../__tests__/usePagesOrInfinite.spec.ts | 89 +++++++++++-------- .../{usePlans.spec.ts => usePlans.spec.tsx} | 20 +++-- .../hooks/__tests__/useSubscription.spec.tsx | 13 +-- .../src/react/hooks/__tests__/wrapper.tsx | 12 +++ 5 files changed, 120 insertions(+), 96 deletions(-) rename packages/shared/src/react/hooks/__tests__/{createBillingPaginatedHook.spec.ts => createBillingPaginatedHook.spec.tsx} (75%) rename packages/shared/src/react/hooks/__tests__/{usePlans.spec.ts => usePlans.spec.tsx} (90%) create mode 100644 packages/shared/src/react/hooks/__tests__/wrapper.tsx diff --git a/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.ts b/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx similarity index 75% rename from packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.ts rename to packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx index 40a056b9486..71c86906a9a 100644 --- a/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.ts +++ b/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx @@ -3,6 +3,7 @@ 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' }; @@ -32,9 +33,8 @@ vi.mock('../../contexts', () => { type DummyResource = { id: string } & ClerkResource; type DummyParams = { initialPage?: number; pageSize?: number } & { orgId?: string }; -const useFetcherMock = vi.fn(() => { - return vi.fn(); -}); +const fetcherMock = vi.fn(); +const useFetcherMock = vi.fn(() => fetcherMock); const useDummyAuth = createBillingPaginatedHook({ hookName: 'useDummyAuth', @@ -60,26 +60,25 @@ describe('createBillingPaginatedHook', () => { }); it('fetches with default params when called with no params', async () => { - const { result } = renderHook(() => useDummyAuth()); + const { result } = renderHook(() => useDummyAuth(), { wrapper }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(useFetcherMock).toHaveBeenCalledWith('user'); + expect(fetcherMock).toHaveBeenCalled(); - const fetcher = useFetcherMock.mock.results[0].value; - expect(fetcher).toHaveBeenCalled(); - expect(fetcher.mock.calls[0][0]).toStrictEqual({ initialPage: 1, pageSize: 10 }); + // 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 })); + 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'); - const fetcher = useFetcherMock.mock.results[0].value; - expect(fetcher).not.toHaveBeenCalled(); + expect(fetcherMock).not.toHaveBeenCalled(); expect(result.current.isLoading).toBe(false); expect(result.current.data).toEqual([]); }); @@ -87,38 +86,35 @@ describe('createBillingPaginatedHook', () => { it('does not fetch when billing disabled (user)', () => { mockClerk.__unstable__environment.commerceSettings.billing.user.enabled = false; - const { result } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 5 })); + const { result } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 4 }), { wrapper }); expect(useFetcherMock).toHaveBeenCalledWith('user'); - const fetcher = useFetcherMock.mock.results[0].value; - expect(fetcher).not.toHaveBeenCalled(); + expect(fetcherMock).not.toHaveBeenCalled(); expect(result.current.isLoading).toBe(false); }); it('authenticated hook: does not fetch when user is null', () => { mockUser = null; - const { result } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 5 })); + const { result } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 4 }), { wrapper }); expect(useFetcherMock).toHaveBeenCalledWith('user'); - const fetcher = useFetcherMock.mock.results[0].value; - expect(fetcher).not.toHaveBeenCalled(); + 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 })); + const { result } = renderHook(() => useDummyUnauth({ initialPage: 1, pageSize: 4 }), { wrapper }); - await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(useFetcherMock).toHaveBeenCalledWith('user'); - - const fetcher = useFetcherMock.mock.results[0].value; - expect(fetcher).toHaveBeenCalled(); - expect(useFetcherMock.mock.results[0].value.mock.calls[0][0]).toStrictEqual({ initialPage: 1, pageSize: 4 }); + 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', () => { @@ -126,10 +122,10 @@ describe('createBillingPaginatedHook', () => { mockClerk.__unstable__environment.commerceSettings.billing.user.enabled = false; mockClerk.__unstable__environment.commerceSettings.billing.organization.enabled = false; - const { result } = renderHook(() => useDummyUnauth({ initialPage: 1, pageSize: 4 })); + const { result } = renderHook(() => useDummyUnauth({ initialPage: 1, pageSize: 4 }), { wrapper }); expect(useFetcherMock).toHaveBeenCalledWith('user'); - expect(useFetcherMock.mock.results[0].value).not.toHaveBeenCalled(); + expect(fetcherMock).not.toHaveBeenCalled(); expect(result.current.isLoading).toBe(false); expect(result.current.data).toEqual([]); @@ -139,46 +135,50 @@ describe('createBillingPaginatedHook', () => { mockClerk.__unstable__environment.commerceSettings.billing.organization.enabled = false; mockClerk.__unstable__environment.commerceSettings.billing.user.enabled = true; - const { result } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 5 })); + const { result } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 4 }), { wrapper }); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => expect(result.current.isLoading).toBe(true)); expect(useFetcherMock).toHaveBeenCalledWith('user'); - const fetcher = useFetcherMock.mock.results[0].value; - expect(fetcher.mock.calls[0][0]).toStrictEqual({ initialPage: 1, pageSize: 5 }); + 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: 7, for: 'organization' } as any)); + const { result } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 4, for: 'organization' } as any), { + wrapper, + }); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => expect(result.current.isLoading).toBe(true)); expect(useFetcherMock).toHaveBeenCalledWith('organization'); - const fetcher = useFetcherMock.mock.results[0].value; - expect(fetcher.mock.calls[0][0]).toStrictEqual({ + expect(fetcherMock.mock.calls[0][0]).toStrictEqual({ initialPage: 1, - pageSize: 7, + pageSize: 4, orgId: 'org_1', }); }); - it('does not fetch in organization mode when organization billing disabled', () => { + 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: 5, for: 'organization' } as any)); + 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'); - const fetcher = useFetcherMock.mock.results[0].value; - expect(fetcher).not.toHaveBeenCalled(); + expect(fetcherMock).not.toHaveBeenCalled(); expect(result.current.isLoading).toBe(false); }); - it('unauthenticated hook: does not fetch in organization mode when organization billing disabled', () => { + 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: 5, for: 'organization' } as any)); + 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'); - const fetcher = useFetcherMock.mock.results[0].value; - expect(fetcher).not.toHaveBeenCalled(); + expect(fetcherMock).not.toHaveBeenCalled(); expect(result.current.isLoading).toBe(false); }); }); diff --git a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts index 11c6d1ea24a..44d01d5ccea 100644 --- a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts +++ b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts @@ -3,6 +3,7 @@ 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(() => { @@ -22,8 +23,9 @@ describe('usePagesOrInfinite - basic pagination', () => { 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), + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, ); // wait until SWR mock finishes fetching @@ -78,11 +80,12 @@ describe('usePagesOrInfinite - request params and getDifferentKeys', () => { 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), + 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)); + await waitFor(() => expect(result.current.isLoading).toBe(true)); // First call: should include provided params, not include cache keys expect(fetcher).toHaveBeenCalledTimes(1); @@ -122,8 +125,9 @@ describe('usePagesOrInfinite - infinite mode', () => { 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), + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, ); // first render should fetch first page @@ -179,8 +183,9 @@ describe('usePagesOrInfinite - disabled and isSignedIn gating', () => { 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), + 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 @@ -197,8 +202,9 @@ describe('usePagesOrInfinite - disabled and isSignedIn gating', () => { 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), + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, ); expect(fetcher).toHaveBeenCalledTimes(0); @@ -213,7 +219,7 @@ describe('usePagesOrInfinite - disabled and isSignedIn gating', () => { 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)); + const { result } = renderHook(() => usePagesOrInfinite(params, fetcher, config, cacheKeys), { wrapper }); expect(fetcher).toHaveBeenCalledTimes(0); expect(result.current.data).toEqual([]); @@ -229,8 +235,9 @@ describe('usePagesOrInfinite - cache mode', () => { 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), + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, ); // Should never be fetching in cache mode @@ -266,8 +273,9 @@ describe('usePagesOrInfinite - keepPreviousData behavior', () => { 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), + 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' }]); @@ -308,7 +316,7 @@ describe('usePagesOrInfinite - sign-out hides previously loaded data', () => { { ...baseConfig, isSignedIn: signedIn } as any, cacheKeys as any, ), - { initialProps: { signedIn: true } }, + { initialProps: { signedIn: true }, wrapper }, ); await waitFor(() => expect(result.current.isFetching).toBe(true)); @@ -346,7 +354,7 @@ describe('usePagesOrInfinite - sign-out hides previously loaded data', () => { { ...baseConfig, isSignedIn: signedIn } as any, cacheKeys as any, ), - { initialProps: { signedIn: true } }, + { initialProps: { signedIn: true }, wrapper }, ); await waitFor(() => expect(result.current.isFetching).toBe(true)); @@ -376,8 +384,9 @@ describe('usePagesOrInfinite - pagination helpers', () => { 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), + 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)); @@ -408,8 +417,9 @@ describe('usePagesOrInfinite - pagination helpers', () => { 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), + 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)); @@ -445,8 +455,9 @@ describe('usePagesOrInfinite - behaviors mirrored from useCoreOrganization', () 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), + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, ); // initial @@ -488,8 +499,9 @@ describe('usePagesOrInfinite - behaviors mirrored from useCoreOrganization', () 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), + 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)); @@ -528,8 +540,9 @@ describe('usePagesOrInfinite - revalidate behavior', () => { 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), + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, ); // Wait for initial load @@ -573,8 +586,9 @@ describe('usePagesOrInfinite - revalidate behavior', () => { 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), + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, ); // Wait for initial load @@ -618,8 +632,9 @@ describe('usePagesOrInfinite - revalidate behavior', () => { 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), + const { result } = renderHook( + () => usePagesOrInfinite(params as any, fetcher as any, config as any, cacheKeys as any), + { wrapper }, ); // Wait for initial page load @@ -677,8 +692,9 @@ describe('usePagesOrInfinite - error propagation', () => { 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), + 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)); @@ -693,8 +709,9 @@ describe('usePagesOrInfinite - error propagation', () => { 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), + 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)); diff --git a/packages/shared/src/react/hooks/__tests__/usePlans.spec.ts b/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx similarity index 90% rename from packages/shared/src/react/hooks/__tests__/usePlans.spec.ts rename to packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx index 2cc07ebde96..bc8d0aa6437 100644 --- a/packages/shared/src/react/hooks/__tests__/usePlans.spec.ts +++ b/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx @@ -38,6 +38,7 @@ vi.mock('../../contexts', () => { }); import { usePlans } from '../usePlans'; +import { wrapper } from './wrapper'; describe('usePlans', () => { beforeEach(() => { @@ -49,7 +50,7 @@ describe('usePlans', () => { it('does not call fetcher when clerk.loaded is false', () => { mockClerk.loaded = false; - const { result } = renderHook(() => usePlans({ initialPage: 1, pageSize: 5 })); + const { result } = renderHook(() => usePlans({ initialPage: 1, pageSize: 5 }), { wrapper }); expect(getPlansSpy).not.toHaveBeenCalled(); expect(result.current.isLoading).toBe(false); @@ -58,7 +59,7 @@ describe('usePlans', () => { }); it('fetches plans for user when loaded', async () => { - const { result } = renderHook(() => usePlans({ initialPage: 1, pageSize: 5 })); + const { result } = renderHook(() => usePlans({ initialPage: 1, pageSize: 5 }), { wrapper }); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -71,7 +72,9 @@ describe('usePlans', () => { }); it('fetches plans for organization when for=organization', async () => { - const { result } = renderHook(() => usePlans({ initialPage: 1, pageSize: 5, for: 'organization' } as any)); + const { result } = renderHook(() => usePlans({ initialPage: 1, pageSize: 5, for: 'organization' } as any), { + wrapper, + }); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -86,13 +89,14 @@ describe('usePlans', () => { // simulate no user mockUser.id = undefined; - const { result } = renderHook(() => usePlans({ initialPage: 1, pageSize: 4 })); + const { result } = renderHook(() => usePlans({ initialPage: 1, pageSize: 4 }), { wrapper }); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + 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 }); expect(getPlansSpy.mock.calls[0][0].orgId).toBeUndefined(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.data.length).toBe(4); }); @@ -100,9 +104,11 @@ describe('usePlans', () => { // simulate no organization id mockOrganization.id = undefined; - const { result } = renderHook(() => usePlans({ initialPage: 1, pageSize: 3, for: 'organization' } as any)); + const { result } = renderHook(() => usePlans({ initialPage: 1, pageSize: 3, for: 'organization' } as any), { + wrapper, + }); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => expect(result.current.isLoading).toBe(true)); expect(getPlansSpy).toHaveBeenCalledTimes(1); expect(getPlansSpy.mock.calls[0][0]).toStrictEqual({ for: 'organization', pageSize: 3, initialPage: 1 }); diff --git a/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx b/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx index e3fbd15e9c2..9907cfe4e09 100644 --- a/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/useSubscription.spec.tsx @@ -1,9 +1,8 @@ import { renderHook, waitFor } from '@testing-library/react'; -import React from 'react'; -import { SWRConfig } from 'swr'; 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' }; @@ -16,16 +15,6 @@ const getSubscriptionSpy = vi.fn((args?: { orgId?: string }) => Promise.resolve({ id: args?.orgId ? `sub_org_${args.orgId}` : 'sub_user_user_1' }), ); -const wrapper = ({ children }: { children: React.ReactNode }) => ( - new Map(), - }} - > - {children} - -); - const mockClerk = { loaded: true, billing: { 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} + +); From ca591d6d80722b91a4c56527564e6029820a188f Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 15 Oct 2025 15:37:47 +0300 Subject: [PATCH 13/16] Add changeset --- .changeset/seven-turkeys-sin.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/seven-turkeys-sin.md diff --git a/.changeset/seven-turkeys-sin.md b/.changeset/seven-turkeys-sin.md new file mode 100644 index 00000000000..04a4db7f6ce --- /dev/null +++ b/.changeset/seven-turkeys-sin.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Bug fix for billing hooks that would sometime fire requests while the user was signed out. From 18944d70de47e07dde8d3081ab16c99124ea6081 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 15 Oct 2025 15:49:19 +0300 Subject: [PATCH 14/16] usePlans should not be triggering requests based on userId changes and orgId changes as it returns unauthenticated data. --- .../createBillingPaginatedHook.spec.tsx | 218 ++++++++++++++++++ .../__tests__/usePagesOrInfinite.spec.ts | 77 ------- .../react/hooks/__tests__/usePlans.spec.tsx | 10 +- .../hooks/createBillingPaginatedHook.tsx | 11 +- .../src/react/hooks/usePagesOrInfinite.ts | 57 ++++- packages/shared/src/react/hooks/usePlans.tsx | 5 +- 6 files changed, 286 insertions(+), 92 deletions(-) diff --git a/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx b/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx index 71c86906a9a..da7b556adc0 100644 --- a/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx @@ -91,7 +91,9 @@ describe('createBillingPaginatedHook', () => { 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', () => { @@ -181,4 +183,220 @@ describe('createBillingPaginatedHook', () => { 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 index 44d01d5ccea..0bb871e4b58 100644 --- a/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts +++ b/packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts @@ -295,83 +295,6 @@ describe('usePagesOrInfinite - keepPreviousData behavior', () => { }); }); -describe('usePagesOrInfinite - sign-out hides previously loaded data', () => { - it('pagination mode: data is cleared when isSignedIn switches to false', async () => { - const fetcher = vi.fn((p: any) => - Promise.resolve({ - data: Array.from({ length: p.pageSize }, (_, i) => ({ id: `p${p.initialPage}-${i}` })), - total_count: 5, - }), - ); - - const params = { initialPage: 1, pageSize: 2 } as const; - const baseConfig = { infinite: false, enabled: true } as const; - const cacheKeys = { type: 't-signedout-paginated' } as const; - - const { result, rerender } = renderHook( - ({ signedIn }) => - usePagesOrInfinite( - params as any, - fetcher as any, - { ...baseConfig, isSignedIn: signedIn } as any, - cacheKeys as any, - ), - { initialProps: { signedIn: 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(3); // ceil(5/2) - - // simulate sign-out - rerender({ signedIn: false }); - // 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('infinite mode: data is cleared when isSignedIn switches to false', async () => { - const fetcher = vi.fn((p: any) => - Promise.resolve({ - data: Array.from({ length: p.pageSize }, (_, i) => ({ id: `p${p.initialPage}-${i}` })), - total_count: 10, - }), - ); - - const params = { initialPage: 1, pageSize: 2 } as const; - const baseConfig = { infinite: true, enabled: true } as const; - const cacheKeys = { type: 't-signedout-infinite' } as const; - - const { result, rerender } = renderHook( - ({ signedIn }) => - usePagesOrInfinite( - params as any, - fetcher as any, - { ...baseConfig, isSignedIn: signedIn } as any, - cacheKeys as any, - ), - { initialProps: { signedIn: 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 - rerender({ signedIn: false }); - 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('usePagesOrInfinite - pagination helpers', () => { it('computes pageCount/hasNext/hasPrevious correctly for initialPage>1', async () => { const totalCount = 37; diff --git a/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx b/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx index bc8d0aa6437..926b7cb6c3d 100644 --- a/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/usePlans.spec.tsx @@ -65,8 +65,7 @@ describe('usePlans', () => { expect(getPlansSpy).toHaveBeenCalledTimes(1); // ensure correct args passed: for: 'user' and limit/page (rest) - expect(getPlansSpy.mock.calls[0][0]).toMatchObject({ for: 'user' }); - expect(getPlansSpy.mock.calls[0][0].orgId).toBeUndefined(); + 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); }); @@ -79,9 +78,8 @@ describe('usePlans', () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(getPlansSpy).toHaveBeenCalledTimes(1); - expect(getPlansSpy.mock.calls[0][0]).toMatchObject({ for: 'organization' }); // orgId must not leak to fetcher - expect(getPlansSpy.mock.calls[0][0].orgId).toBeUndefined(); + expect(getPlansSpy.mock.calls[0][0]).toStrictEqual({ for: 'organization', initialPage: 1, pageSize: 5 }); expect(result.current.data.length).toBe(5); }); @@ -95,7 +93,6 @@ describe('usePlans', () => { expect(getPlansSpy).toHaveBeenCalledTimes(1); expect(getPlansSpy.mock.calls[0][0]).toStrictEqual({ for: 'user', pageSize: 4, initialPage: 1 }); - expect(getPlansSpy.mock.calls[0][0].orgId).toBeUndefined(); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.data.length).toBe(4); }); @@ -111,9 +108,8 @@ describe('usePlans', () => { 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 }); - // orgId should not leak to fetcher - expect(getPlansSpy.mock.calls[0][0].orgId).toBeUndefined(); expect(result.current.data.length).toBe(3); }); }); 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 PaginatedResources, TConfig['infinite']>; +/** + * + */ +export function usePrevious(value: T) { + const [current, setCurrent] = useState(value); + const [previous, setPrevious] = useState(null); + + if (value !== current) { + setPrevious(current); + setCurrent(value); + } + + return previous; +} + /** * A flexible pagination hook that supports both traditional pagination and infinite loading. * It provides a unified API for handling paginated data fetching, with built-in caching through SWR. @@ -157,10 +172,34 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, pageSize: pageSizeRef.current, }; + const previousIsSignedIn = usePrevious(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 === false ? null : 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 @@ -181,6 +220,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, 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, From 732ddb408ced4b87f2d4379929ae76573ce7cf33 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 15 Oct 2025 17:41:27 +0300 Subject: [PATCH 15/16] update changeset --- .changeset/seven-turkeys-sin.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/seven-turkeys-sin.md b/.changeset/seven-turkeys-sin.md index 04a4db7f6ce..4a437c78c0f 100644 --- a/.changeset/seven-turkeys-sin.md +++ b/.changeset/seven-turkeys-sin.md @@ -2,4 +2,6 @@ '@clerk/shared': patch --- -Bug fix for billing hooks that would sometime fire requests while the user was signed out. +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. From 019515371c8fdacb04952053b12a1798ab2d2517 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 17 Oct 2025 15:08:47 +0300 Subject: [PATCH 16/16] add tests for previous value --- .../hooks/__tests__/usePreviousValue.spec.ts | 53 +++++++++++++++++++ .../src/react/hooks/usePagesOrInfinite.ts | 18 +------ .../src/react/hooks/usePreviousValue.ts | 30 +++++++++++ 3 files changed, 85 insertions(+), 16 deletions(-) create mode 100644 packages/shared/src/react/hooks/__tests__/usePreviousValue.spec.ts create mode 100644 packages/shared/src/react/hooks/usePreviousValue.ts 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/usePagesOrInfinite.ts b/packages/shared/src/react/hooks/usePagesOrInfinite.ts index b278fc4abd8..b5d0c5aed45 100644 --- a/packages/shared/src/react/hooks/usePagesOrInfinite.ts +++ b/packages/shared/src/react/hooks/usePagesOrInfinite.ts @@ -10,6 +10,7 @@ import type { PaginatedResources, ValueOrSetter, } from '../types'; +import { usePreviousValue } from './usePreviousValue'; /** * Returns an object containing only the keys from the first object that are not present in the second object. @@ -121,21 +122,6 @@ type UsePagesOrInfinite = < cacheKeys: CacheKeys, ) => PaginatedResources, TConfig['infinite']>; -/** - * - */ -export function usePrevious(value: T) { - const [current, setCurrent] = useState(value); - const [previous, setPrevious] = useState(null); - - if (value !== current) { - setPrevious(current); - setCurrent(value); - } - - return previous; -} - /** * A flexible pagination hook that supports both traditional pagination and infinite loading. * It provides a unified API for handling paginated data fetching, with built-in caching through SWR. @@ -172,7 +158,7 @@ export const usePagesOrInfinite: UsePagesOrInfinite = (params, fetcher, config, pageSize: pageSizeRef.current, }; - const previousIsSignedIn = usePrevious(isSignedIn); + 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. 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; +}