Skip to content

Commit 00291bc

Browse files
authored
feat(shared): Lax revalidation for RQ variant hooks (#7228)
1 parent 6d3c66d commit 00291bc

File tree

8 files changed

+460
-26
lines changed

8 files changed

+460
-26
lines changed

.changeset/young-impalas-grab.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@clerk/shared': patch
3+
---
4+
5+
Relaxing requirements for RQ variant hooks to enable revalidation across different configurations of the same hook.
6+
7+
```tsx
8+
9+
const { revalidate } = useStatements({ initialPage: 1, pageSize: 10 });
10+
useStatements({ initialPage: 1, pageSize: 12 });
11+
12+
// revalidate from first hook, now invalidates the second hook.
13+
void revalidate();
14+
```

packages/shared/src/react/hooks/__tests__/createBillingPaginatedHook.spec.tsx

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { renderHook, waitFor } from '@testing-library/react';
1+
import { act, renderHook, waitFor } from '@testing-library/react';
22
import { beforeEach, describe, expect, it, vi } from 'vitest';
33

44
import type { ClerkResource } from '../../../types';
@@ -486,4 +486,66 @@ describe('createBillingPaginatedHook', () => {
486486
expect(result.current.pageCount).toBe(5);
487487
});
488488
});
489+
490+
describe('revalidate behavior', () => {
491+
it('revalidate fetches fresh data for authenticated hook', async () => {
492+
fetcherMock
493+
.mockResolvedValueOnce({
494+
data: [{ id: 'initial-1' } as DummyResource, { id: 'initial-2' } as DummyResource],
495+
total_count: 2,
496+
})
497+
.mockResolvedValueOnce({
498+
data: [{ id: 'refetched-1' } as DummyResource, { id: 'refetched-2' } as DummyResource],
499+
total_count: 2,
500+
});
501+
502+
const { result } = renderHook(() => useDummyAuth({ initialPage: 1, pageSize: 2 }), { wrapper });
503+
504+
await waitFor(() => expect(result.current.isLoading).toBe(false));
505+
expect(result.current.data).toEqual([{ id: 'initial-1' }, { id: 'initial-2' }]);
506+
507+
await act(async () => {
508+
await result.current.revalidate();
509+
});
510+
511+
await waitFor(() => expect(result.current.data).toEqual([{ id: 'refetched-1' }, { id: 'refetched-2' }]));
512+
expect(fetcherMock).toHaveBeenCalledTimes(2);
513+
});
514+
515+
it('revalidate propagates to infinite counterpart only for React Query', async () => {
516+
let seq = 0;
517+
fetcherMock.mockImplementation(async (params: DummyParams) => {
518+
seq++;
519+
return {
520+
data: Array.from({ length: params.pageSize ?? 2 }, (_, i) => ({
521+
id: `item-${params.initialPage ?? 1}-${seq}-${i}`,
522+
})) as DummyResource[],
523+
total_count: 10,
524+
};
525+
});
526+
527+
const useBoth = () => {
528+
const paginated = useDummyAuth({ initialPage: 1, pageSize: 2 });
529+
const infinite = useDummyAuth({ initialPage: 1, pageSize: 2, infinite: true } as any);
530+
return { paginated, infinite };
531+
};
532+
533+
const { result } = renderHook(useBoth, { wrapper });
534+
535+
await waitFor(() => expect(result.current.paginated.isLoading).toBe(false));
536+
await waitFor(() => expect(result.current.infinite.isLoading).toBe(false));
537+
538+
fetcherMock.mockClear();
539+
540+
await act(async () => {
541+
await result.current.paginated.revalidate();
542+
});
543+
544+
if (__CLERK_USE_RQ__) {
545+
await waitFor(() => expect(fetcherMock.mock.calls.length).toBeGreaterThanOrEqual(2));
546+
} else {
547+
await waitFor(() => expect(fetcherMock).toHaveBeenCalledTimes(1));
548+
}
549+
});
550+
});
489551
});
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { act, renderHook, waitFor } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { useAPIKeys } from '../useAPIKeys';
5+
import { createMockClerk, createMockQueryClient } from './mocks/clerk';
6+
import { wrapper } from './wrapper';
7+
8+
const getAllSpy = vi.fn(
9+
async () =>
10+
({
11+
data: [],
12+
total_count: 0,
13+
}) as { data: Array<Record<string, unknown>>; total_count: number },
14+
);
15+
16+
const defaultQueryClient = createMockQueryClient();
17+
18+
const mockClerk = createMockClerk({
19+
apiKeys: {
20+
getAll: getAllSpy,
21+
},
22+
queryClient: defaultQueryClient,
23+
});
24+
25+
vi.mock('../../contexts', () => {
26+
return {
27+
useAssertWrappedByClerkProvider: () => {},
28+
useClerkInstanceContext: () => mockClerk,
29+
};
30+
});
31+
32+
describe('useApiKeys', () => {
33+
beforeEach(() => {
34+
vi.clearAllMocks();
35+
defaultQueryClient.client.clear();
36+
mockClerk.loaded = true;
37+
mockClerk.user = { id: 'user_1' };
38+
});
39+
40+
it('revalidate fetches fresh API keys', async () => {
41+
getAllSpy
42+
.mockResolvedValueOnce({
43+
data: [{ id: 'key_initial' }],
44+
total_count: 1,
45+
})
46+
.mockResolvedValueOnce({
47+
data: [{ id: 'key_updated' }],
48+
total_count: 1,
49+
});
50+
51+
const { result } = renderHook(() => useAPIKeys({ subject: 'user_1', pageSize: 1 }), { wrapper });
52+
53+
await waitFor(() => expect(result.current.isLoading).toBe(false));
54+
expect(result.current.data).toEqual([{ id: 'key_initial' }]);
55+
56+
await act(async () => {
57+
await result.current.revalidate();
58+
});
59+
60+
await waitFor(() => expect(result.current.data).toEqual([{ id: 'key_updated' }]));
61+
expect(getAllSpy).toHaveBeenCalledTimes(2);
62+
});
63+
64+
it('cascades revalidation for related queries only when using React Query', async () => {
65+
let sequence = 0;
66+
getAllSpy.mockImplementation(async ({ initialPage }: { initialPage?: number } = {}) => {
67+
sequence += 1;
68+
const page = initialPage ?? 1;
69+
return {
70+
data: [{ id: `key-${page}-${sequence}` }],
71+
total_count: 5,
72+
};
73+
});
74+
75+
const useBoth = () => {
76+
const paginated = useAPIKeys({ subject: 'user_1', pageSize: 1 });
77+
const infinite = useAPIKeys({ subject: 'user_1', pageSize: 1, infinite: true });
78+
return { paginated, infinite };
79+
};
80+
81+
const { result } = renderHook(useBoth, { wrapper });
82+
83+
await waitFor(() => expect(result.current.paginated.isLoading).toBe(false));
84+
await waitFor(() => expect(result.current.infinite.isLoading).toBe(false));
85+
86+
getAllSpy.mockClear();
87+
88+
await act(async () => {
89+
await result.current.paginated.revalidate();
90+
});
91+
92+
const isRQ = Boolean((globalThis as any).__CLERK_USE_RQ__);
93+
94+
if (isRQ) {
95+
await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(2));
96+
} else {
97+
await waitFor(() => expect(getAllSpy).toHaveBeenCalledTimes(1));
98+
}
99+
});
100+
101+
it('handles revalidation with different pageSize configurations', async () => {
102+
let seq = 0;
103+
getAllSpy.mockImplementation(async ({ pageSize }: { pageSize?: number } = {}) => {
104+
seq += 1;
105+
return {
106+
data: [{ id: `key-pageSize-${pageSize ?? 'unknown'}-${seq}` }],
107+
total_count: 3,
108+
};
109+
});
110+
111+
const useHooks = () => {
112+
const small = useAPIKeys({ subject: 'user_1', pageSize: 1 });
113+
const large = useAPIKeys({ subject: 'user_1', pageSize: 5 });
114+
return { small, large };
115+
};
116+
117+
const { result } = renderHook(useHooks, { wrapper });
118+
119+
await waitFor(() => expect(result.current.small.isLoading).toBe(false));
120+
await waitFor(() => expect(result.current.large.isLoading).toBe(false));
121+
122+
getAllSpy.mockClear();
123+
124+
await act(async () => {
125+
await result.current.small.revalidate();
126+
});
127+
128+
const isRQ = Boolean((globalThis as any).__CLERK_USE_RQ__);
129+
130+
await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(1));
131+
132+
if (isRQ) {
133+
await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(2));
134+
} else {
135+
expect(getAllSpy).toHaveBeenCalledTimes(1);
136+
}
137+
});
138+
139+
it('handles revalidation with different query filters', async () => {
140+
let seq = 0;
141+
getAllSpy.mockImplementation(async ({ query }: { query?: string } = {}) => {
142+
seq += 1;
143+
return {
144+
data: [{ id: `key-query-${query ?? 'empty'}-${seq}` }],
145+
total_count: 2,
146+
};
147+
});
148+
149+
const useHooks = () => {
150+
const defaultQuery = useAPIKeys({ subject: 'user_1', pageSize: 11, query: '' });
151+
const filtered = useAPIKeys({ subject: 'user_1', pageSize: 11, query: 'search' });
152+
return { defaultQuery, filtered };
153+
};
154+
155+
const { result } = renderHook(useHooks, { wrapper });
156+
157+
await waitFor(() => expect(result.current.defaultQuery.isLoading).toBe(false));
158+
await waitFor(() => expect(result.current.filtered.isLoading).toBe(false));
159+
160+
getAllSpy.mockClear();
161+
162+
await act(async () => {
163+
await result.current.defaultQuery.revalidate();
164+
});
165+
166+
const isRQ = Boolean((globalThis as any).__CLERK_USE_RQ__);
167+
168+
await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(1));
169+
170+
if (isRQ) {
171+
await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(2));
172+
} else {
173+
expect(getAllSpy).toHaveBeenCalledTimes(1);
174+
}
175+
});
176+
177+
it('does not cascade revalidation across different subjects', async () => {
178+
let seq = 0;
179+
getAllSpy.mockImplementation(async ({ subject }: { subject?: string } = {}) => {
180+
seq += 1;
181+
return {
182+
data: [{ id: `key-subject-${subject ?? 'none'}-${seq}` }],
183+
total_count: 4,
184+
};
185+
});
186+
187+
const useHooks = () => {
188+
const primary = useAPIKeys({ subject: 'user_primary', pageSize: 1 });
189+
const secondary = useAPIKeys({ subject: 'user_secondary', pageSize: 1 });
190+
return { primary, secondary };
191+
};
192+
193+
const { result } = renderHook(useHooks, { wrapper });
194+
195+
await waitFor(() => expect(result.current.primary.isLoading).toBe(false));
196+
await waitFor(() => expect(result.current.secondary.isLoading).toBe(false));
197+
198+
getAllSpy.mockClear();
199+
200+
await act(async () => {
201+
await result.current.primary.revalidate();
202+
});
203+
204+
await waitFor(() => expect(getAllSpy.mock.calls.length).toBeGreaterThanOrEqual(1));
205+
206+
expect(getAllSpy).toHaveBeenCalledTimes(1);
207+
const subjects = (getAllSpy.mock.calls as Array<unknown[]>).map(
208+
call => (call[0] as { subject?: string } | undefined)?.subject,
209+
);
210+
expect(subjects).not.toContain('user_secondary');
211+
expect(subjects[0] === undefined || subjects[0] === 'user_primary').toBe(true);
212+
});
213+
});

packages/shared/src/react/hooks/__tests__/usePagesOrInfinite.spec.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,35 @@ describe('usePagesOrInfinite - behaviors mirrored from useCoreOrganization', ()
503503
});
504504

505505
describe('usePagesOrInfinite - revalidate behavior', () => {
506+
it('refetches current data when revalidate is invoked', async () => {
507+
const fetcher = vi
508+
.fn()
509+
.mockResolvedValueOnce({
510+
data: [{ id: 'initial-1' }],
511+
total_count: 1,
512+
})
513+
.mockResolvedValueOnce({
514+
data: [{ id: 'refetched-1' }],
515+
total_count: 1,
516+
});
517+
518+
const params = { initialPage: 1, pageSize: 1 };
519+
const config = buildConfig(params);
520+
const keys = buildKeys('t-revalidate-refresh', params);
521+
522+
const { result } = renderUsePagesOrInfinite({ fetcher, config, keys });
523+
524+
await waitFor(() => expect(result.current.isLoading).toBe(false));
525+
expect(result.current.data).toEqual([{ id: 'initial-1' }]);
526+
527+
await act(async () => {
528+
await (result.current as any).revalidate();
529+
});
530+
531+
await waitFor(() => expect(result.current.data).toEqual([{ id: 'refetched-1' }]));
532+
expect(fetcher).toHaveBeenCalledTimes(2);
533+
});
534+
506535
it('pagination mode: isFetching toggles during revalidate, isLoading stays false after initial load', async () => {
507536
const deferred = createDeferredPromise();
508537
let callCount = 0;
@@ -652,6 +681,47 @@ describe('usePagesOrInfinite - revalidate behavior', () => {
652681
{ id: 'p2-1-revalidate' },
653682
]);
654683
});
684+
685+
it('cascades revalidation to related queries only in React Query mode', async () => {
686+
const params = { initialPage: 1, pageSize: 1 };
687+
const keys = buildKeys('t-revalidate-cascade', params, { userId: 'user_123' });
688+
const fetcher = vi.fn(async ({ initialPage }: any) => ({
689+
data: [{ id: `item-${initialPage}-${fetcher.mock.calls.length}` }],
690+
total_count: 3,
691+
}));
692+
693+
const useBoth = () => {
694+
const paginated = usePagesOrInfinite({
695+
fetcher,
696+
config: buildConfig(params),
697+
keys,
698+
});
699+
const infinite = usePagesOrInfinite({
700+
fetcher,
701+
config: buildConfig(params, { infinite: true }),
702+
keys,
703+
});
704+
705+
return { paginated, infinite };
706+
};
707+
708+
const { result } = renderHook(useBoth, { wrapper });
709+
710+
await waitFor(() => expect(result.current.paginated.isLoading).toBe(false));
711+
await waitFor(() => expect(result.current.infinite.isLoading).toBe(false));
712+
713+
fetcher.mockClear();
714+
715+
await act(async () => {
716+
await result.current.paginated.revalidate();
717+
});
718+
719+
if (__CLERK_USE_RQ__) {
720+
await waitFor(() => expect(fetcher.mock.calls.length).toBeGreaterThanOrEqual(2));
721+
} else {
722+
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
723+
}
724+
});
655725
});
656726

657727
describe('usePagesOrInfinite - error propagation', () => {

0 commit comments

Comments
 (0)