Skip to content

Commit 391aac1

Browse files
committed
fix: improve async handling in tests by using act() for state updates and cleanup
1 parent 3c395d5 commit 391aac1

5 files changed

Lines changed: 242 additions & 16 deletions

File tree

test/react/index.spec.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,9 +285,11 @@ describe('useFetcher', () => {
285285
it('should show loading when no state and URL exists', async () => {
286286
const { result } = renderHook(() => useFetcher(testUrl));
287287

288-
await waitFor(() => {
289-
expect(result.current.isLoading).toBe(true);
290-
});
288+
// isLoading is true synchronously before the fetch resolves
289+
expect(result.current.isLoading).toBe(true);
290+
291+
// Drain pending microtasks from await null in request-handler
292+
await act(async () => {});
291293
});
292294

293295
it('should not show loading when no URL', () => {

test/react/integration/error-handling.spec.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22
* @jest-environment jsdom
33
*/
44
import '@testing-library/jest-dom';
5-
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
5+
import {
6+
render,
7+
screen,
8+
waitFor,
9+
fireEvent,
10+
act,
11+
} from '@testing-library/react';
612
import React from 'react';
713
import {
814
clearMockResponses,
@@ -127,11 +133,15 @@ describe('Error Handling Integration Tests', () => {
127133
});
128134

129135
// Test retry functionality
130-
fireEvent.click(screen.getByTestId('retry-button'));
136+
await act(async () => {
137+
fireEvent.click(screen.getByTestId('retry-button'));
138+
});
131139
expect(screen.getByTestId('connection-loading')).toHaveTextContent(
132140
'Loading...',
133141
);
134-
jest.runAllTimers();
142+
await act(async () => {
143+
jest.runAllTimers();
144+
});
135145

136146
await waitFor(() => {
137147
expect(abortSignal).not.toBeNull();
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
/* eslint-disable @typescript-eslint/no-explicit-any */
5+
import '@testing-library/jest-dom';
6+
import { act, render, screen, waitFor, cleanup } from '@testing-library/react';
7+
import { fetchf } from '../../../src/index';
8+
import { useFetcher } from '../../../src/react/index';
9+
import { clearAllTimeouts } from '../../../src/timeout-wheel';
10+
11+
describe('fetchf deduplication and parking with jsdom and onRequest', () => {
12+
afterEach(() => {
13+
cleanup();
14+
jest.restoreAllMocks();
15+
clearAllTimeouts();
16+
delete (window as any).__dedupeTriggered;
17+
});
18+
19+
it('deduplicates and parks requests when onRequest triggers a second identical fetchf call', async () => {
20+
const testUrl = '/api/onrequest-dedupe';
21+
const cacheKey = 'onrequest-dedupe-key';
22+
let resolveFetch: ((value: Response) => void) | undefined;
23+
let fetchCallCount = 0;
24+
25+
// Mock global.fetch to simulate slow network
26+
global.fetch = jest.fn().mockImplementation(() => {
27+
fetchCallCount++;
28+
return new Promise<Response>((resolve) => {
29+
resolveFetch = resolve;
30+
});
31+
});
32+
33+
// Track results from both calls
34+
let onRequestResult: any = undefined;
35+
36+
// Set up onRequest interceptor that triggers a second fetchf call
37+
const onRequest = async (config: any) => {
38+
if (!onRequestResult) {
39+
// Trigger a second fetchf with the same cacheKey while the first is in-flight
40+
onRequestResult = fetchf(testUrl, {
41+
cacheKey,
42+
dedupeTime: 2000,
43+
});
44+
}
45+
return config;
46+
};
47+
48+
// Start first request with onRequest interceptor
49+
const promise1 = fetchf(testUrl, {
50+
cacheKey,
51+
dedupeTime: 2000,
52+
onRequest,
53+
});
54+
55+
expect(promise1).not.toBeUndefined();
56+
57+
// Allow microtasks to flush so onRequest fires and the first doRequestOnce calls fetch()
58+
await act(async () => {
59+
await new Promise((r) => setTimeout(r, 0));
60+
});
61+
62+
// onRequest triggered a second fetchf that was deduped
63+
expect(onRequestResult).not.toBeUndefined();
64+
expect(promise1).not.toBe(onRequestResult); // Different promise objects, but parked
65+
66+
// Only one actual fetch call should have been made (the second was deduped)
67+
expect(fetchCallCount).toBe(1);
68+
69+
// Resolve the fetch with a proper mock response
70+
await act(async () => {
71+
if (resolveFetch) {
72+
resolveFetch({
73+
ok: true,
74+
status: 200,
75+
data: { result: 'deduped-onrequest' },
76+
body: JSON.stringify({ result: 'deduped-onrequest' }),
77+
headers: { 'content-type': 'application/json' },
78+
} as unknown as Response);
79+
}
80+
});
81+
82+
// Both promises should resolve to the same result
83+
const [result1, result2] = await Promise.all([promise1, onRequestResult]);
84+
expect(result1.data).toEqual({ result: 'deduped-onrequest' });
85+
expect(result2.data).toEqual({ result: 'deduped-onrequest' });
86+
expect(fetchCallCount).toBe(1);
87+
});
88+
89+
it('useFetcher deduplication: onRequest triggers fetchf with same cacheKey, other useFetcher waits for it', async () => {
90+
const testUrl = '/api/component-dedupe';
91+
const cacheKey = 'component-dedupe-key';
92+
let resolveFetch: ((value: Response) => void) | undefined;
93+
let fetchCallCount = 0;
94+
95+
// Mock global.fetch to simulate slow network
96+
global.fetch = jest.fn().mockImplementation(() => {
97+
fetchCallCount++;
98+
return new Promise<Response>((resolve) => {
99+
resolveFetch = resolve;
100+
});
101+
});
102+
103+
// React test component that triggers the fetch
104+
function DedupeComponent() {
105+
const { data, isLoading } = useFetcher(testUrl, {
106+
cacheKey,
107+
dedupeTime: 2000,
108+
immediate: true,
109+
onRequest: async (config: any) => {
110+
// Only trigger once to avoid infinite loop
111+
if (!(window as any).__dedupeTriggered) {
112+
(window as any).__dedupeTriggered = true;
113+
// This fetchf call uses the same cacheKey and should be deduped
114+
fetchf(testUrl, { cacheKey, dedupeTime: 2000 });
115+
}
116+
return config;
117+
},
118+
});
119+
return (
120+
<div>
121+
<div data-testid="data">
122+
{data ? JSON.stringify(data) : 'No Data'}
123+
</div>
124+
<div data-testid="loading">
125+
{isLoading ? 'Loading' : 'Not Loading'}
126+
</div>
127+
</div>
128+
);
129+
}
130+
131+
// Another useFetcher instance that relies on the same cacheKey
132+
function WaitingComponent() {
133+
const { data, isLoading } = useFetcher(testUrl, {
134+
cacheKey,
135+
dedupeTime: 2000,
136+
immediate: true,
137+
});
138+
return (
139+
<div>
140+
<div data-testid="waiting-data">
141+
{data ? JSON.stringify(data) : 'No Data'}
142+
</div>
143+
<div data-testid="waiting-loading">
144+
{isLoading ? 'Loading' : 'Not Loading'}
145+
</div>
146+
</div>
147+
);
148+
}
149+
150+
// Render both components
151+
render(
152+
<>
153+
<DedupeComponent />
154+
<WaitingComponent />
155+
</>,
156+
);
157+
158+
// Both should be loading initially
159+
expect(screen.getByTestId('loading').textContent).toBe('Loading');
160+
expect(screen.getByTestId('waiting-loading').textContent).toBe('Loading');
161+
162+
// Allow microtasks to flush so the fetch is actually called
163+
await act(async () => {
164+
await new Promise((r) => setTimeout(r, 0));
165+
});
166+
167+
expect(fetchCallCount).toBe(1);
168+
169+
// Resolve the fetch with a proper mock response
170+
await act(async () => {
171+
if (resolveFetch) {
172+
resolveFetch({
173+
ok: true,
174+
status: 200,
175+
data: { result: 'deduped-component' },
176+
body: JSON.stringify({ result: 'deduped-component' }),
177+
headers: { 'content-type': 'application/json' },
178+
} as unknown as Response);
179+
}
180+
});
181+
182+
// Both should show the result and not loading
183+
await waitFor(() => {
184+
expect(screen.getByTestId('data').textContent).toContain(
185+
'deduped-component',
186+
);
187+
expect(screen.getByTestId('loading').textContent).toBe('Not Loading');
188+
expect(screen.getByTestId('waiting-data').textContent).toContain(
189+
'deduped-component',
190+
);
191+
expect(screen.getByTestId('waiting-loading').textContent).toBe(
192+
'Not Loading',
193+
);
194+
});
195+
196+
// Only one network request should have been made
197+
expect(fetchCallCount).toBe(1);
198+
});
199+
});

test/react/integration/hook.spec.tsx

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ describe('React Integration Tests', () => {
3636
jest.useFakeTimers();
3737
});
3838

39-
afterEach(() => {
39+
afterEach(async () => {
40+
await act(async () => {
41+
cleanup();
42+
});
4043
jest.useRealTimers();
4144
jest.resetAllMocks();
4245
clearAllTimeouts();
@@ -1030,18 +1033,25 @@ describe('React Integration Tests', () => {
10301033
);
10311034
};
10321035

1033-
const { rerender } = render(<OverlapComponent phase={1} />);
1036+
let rerender!: ReturnType<typeof render>['rerender'];
1037+
await act(async () => {
1038+
({ rerender } = render(<OverlapComponent phase={1} />));
1039+
});
10341040

10351041
await waitFor(() => {
10361042
expect(screen.getByTestId('overlap-data1')).toHaveTextContent(
10371043
'initial',
10381044
);
10391045
});
10401046

1041-
rerender(<OverlapComponent phase={2} />);
1042-
fireEvent.click(screen.getByTestId('overlap-refetch'));
1047+
await act(async () => {
1048+
rerender(<OverlapComponent phase={2} />);
1049+
fireEvent.click(screen.getByTestId('overlap-refetch'));
1050+
});
10431051

1044-
rerender(<OverlapComponent phase={3} />);
1052+
await act(async () => {
1053+
rerender(<OverlapComponent phase={3} />);
1054+
});
10451055
expect(screen.getByTestId('overlap-phase')).toHaveTextContent('3');
10461056
});
10471057

@@ -1556,9 +1566,12 @@ describe('React Integration Tests', () => {
15561566
const testCase = testCases[index];
15571567
mockFetchResponse(`/api/types-${index}`, testCase);
15581568

1559-
const { unmount } = render(
1560-
<BasicComponent url={`/api/types-${index}`} />,
1561-
);
1569+
let unmount!: ReturnType<typeof render>['unmount'];
1570+
await act(async () => {
1571+
({ unmount } = render(
1572+
<BasicComponent url={`/api/types-${index}`} />,
1573+
));
1574+
});
15621575

15631576
await waitFor(() => {
15641577
const dataText = screen.getByTestId('data').textContent;
@@ -1570,7 +1583,9 @@ describe('React Integration Tests', () => {
15701583
});
15711584

15721585
// Clean up between iterations to avoid multiple elements
1573-
unmount();
1586+
await act(async () => {
1587+
unmount();
1588+
});
15741589
}
15751590
});
15761591

test/react/integration/performance-caching.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -847,7 +847,7 @@ describe('Performance & Caching Integration Tests', () => {
847847
});
848848

849849
// Should start loading non-critical data
850-
act(() => {
850+
await act(async () => {
851851
jest.advanceTimersByTime(600);
852852
});
853853

0 commit comments

Comments
 (0)