Skip to content

Commit 366c147

Browse files
fix: cleanup of waitFor timers when an error occurs during onTimeout printout (#1918)
Co-authored-by: Maciej Jastrzębski <mdjastrzebski@gmail.com>
1 parent e819f77 commit 366c147

3 files changed

Lines changed: 119 additions & 9 deletions

File tree

src/__tests__/wait-for.test.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as React from 'react';
22
import { Pressable, Text, TouchableOpacity, View } from 'react-native';
33

44
import { configure, fireEvent, render, screen, waitFor } from '..';
5+
import { cleanup } from '../pure';
56
import type { TimerType } from '../test-utils/timers';
67
import { setupTimeType } from '../test-utils/timers';
78

@@ -175,6 +176,39 @@ describe('timeout errors', () => {
175176
}),
176177
).rejects.toThrow('Unable to find an element with text: Never appears');
177178
});
179+
180+
test('rejects with onTimeout error when onTimeout callback throws', async () => {
181+
await expect(
182+
waitFor(
183+
() => {
184+
throw new Error('Original timeout error');
185+
},
186+
{
187+
timeout: 10,
188+
onTimeout: () => {
189+
throw new Error('onTimeout failed');
190+
},
191+
},
192+
),
193+
).rejects.toThrow('onTimeout failed');
194+
});
195+
196+
test('rejects with onTimeout value when onTimeout throws a non-Error value', async () => {
197+
await expect(
198+
waitFor(
199+
() => {
200+
throw new Error('Original timeout error');
201+
},
202+
{
203+
timeout: 10,
204+
onTimeout: () => {
205+
// eslint-disable-next-line @typescript-eslint/only-throw-error
206+
throw 'onTimeout failed with string';
207+
},
208+
},
209+
),
210+
).rejects.toBe('onTimeout failed with string');
211+
});
178212
});
179213

180214
describe('error handling', () => {
@@ -235,6 +269,46 @@ describe('error handling', () => {
235269
'Changed from using fake timers to real timers while using waitFor',
236270
);
237271
});
272+
273+
test('cleanup rejects pending waitFor calls and stops real timer polling', async () => {
274+
const expectation = jest.fn(() => {
275+
throw new Error('Not ready yet');
276+
});
277+
278+
const waitForPromise = waitFor(expectation, { timeout: 300, interval: 20 });
279+
280+
await new Promise((resolve) => setTimeout(resolve, 60));
281+
expect(expectation).toHaveBeenCalled();
282+
283+
await cleanup();
284+
await expect(waitForPromise).rejects.toThrow('waitFor was aborted by cleanup');
285+
286+
const callsAfterCleanup = expectation.mock.calls.length;
287+
288+
await new Promise((resolve) => setTimeout(resolve, 60));
289+
expect(expectation).toHaveBeenCalledTimes(callsAfterCleanup);
290+
});
291+
292+
test('async expectation resolving after cleanup abort has no effect', async () => {
293+
let resolveExpectation!: (value: string) => void;
294+
const expectationPromise = new Promise<string>((resolve) => {
295+
resolveExpectation = resolve;
296+
});
297+
298+
const waitForPromise = waitFor(() => expectationPromise, { timeout: 300, interval: 20 });
299+
300+
// Wait for at least one interval tick so the expectation is called and its promise is pending
301+
await new Promise((resolve) => setTimeout(resolve, 30));
302+
303+
// Start cleanup while the expectation promise is still pending
304+
const cleanupPromise = cleanup();
305+
306+
// Resolve the expectation promise while cleanup is in progress
307+
resolveExpectation('success');
308+
309+
await cleanupPromise;
310+
await expect(waitForPromise).rejects.toThrow('waitFor was aborted by cleanup');
311+
});
238312
});
239313

240314
describe('configuration', () => {

src/cleanup.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,7 @@ export async function cleanup() {
1717
export function addToCleanupQueue(fn: CleanUpFunction) {
1818
cleanupQueue.add(fn);
1919
}
20+
21+
export function removeFromCleanupQueue(fn: CleanUpFunction) {
22+
cleanupQueue.delete(fn);
23+
}

src/wait-for.ts

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* globals jest */
22
import { act } from './act';
3+
import { addToCleanupQueue, removeFromCleanupQueue } from './cleanup';
34
import { getConfig } from './config';
45
import { flushMicroTasks } from './flush-micro-tasks';
56
import { copyStackTraceIfNeeded, ErrorWithStack } from './helpers/errors';
@@ -35,11 +36,13 @@ function waitForInternal<T>(
3536

3637
// eslint-disable-next-line no-async-promise-executor
3738
return new Promise(async (resolve, reject) => {
38-
let lastError: unknown, intervalId: ReturnType<typeof setTimeout>;
39+
let lastError: unknown;
40+
let intervalId: ReturnType<typeof setInterval> | null = null;
3941
let finished = false;
4042
let promiseStatus = 'idle';
4143

42-
let overallTimeoutTimer: NodeJS.Timeout | null = null;
44+
let overallTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
45+
const cleanupQueueCallback = () => finalizeWaitFor({ rejectOnAbort: true });
4346

4447
const fakeTimersType = getJestFakeTimersType();
4548

@@ -94,19 +97,42 @@ function waitForInternal<T>(
9497
} else {
9598
overallTimeoutTimer = setTimeout(handleTimeout, timeout);
9699
intervalId = setInterval(checkRealTimersCallback, interval);
100+
addToCleanupQueue(cleanupQueueCallback);
97101
checkExpectation();
98102
}
99103

100-
function onDone(done: { type: 'result'; result: T } | { type: 'error'; error: unknown }) {
104+
function finalizeWaitFor({ rejectOnAbort = false } = {}) {
105+
/* istanbul ignore next */
106+
if (finished) {
107+
return;
108+
}
109+
101110
finished = true;
111+
112+
removeFromCleanupQueue(cleanupQueueCallback);
113+
114+
if (rejectOnAbort) {
115+
reject(new Error('waitFor was aborted by cleanup'));
116+
}
117+
102118
if (overallTimeoutTimer) {
103119
clearTimeout(overallTimeoutTimer);
120+
overallTimeoutTimer = null;
104121
}
105122

106-
if (!fakeTimersType) {
123+
if (intervalId) {
107124
clearInterval(intervalId);
125+
intervalId = null;
126+
}
127+
}
128+
129+
function onDone(done: { type: 'result'; result: T } | { type: 'error'; error: unknown }) {
130+
if (finished) {
131+
return;
108132
}
109133

134+
finalizeWaitFor();
135+
110136
if (done.type === 'error') {
111137
reject(done.error);
112138
} else {
@@ -120,7 +146,7 @@ function waitForInternal<T>(
120146
`Changed from using real timers to fake timers while using waitFor. This is not allowed and will result in very strange behavior. Please ensure you're awaiting all async things your test is doing before changing to fake timers. For more info, please go to https://github.com/testing-library/dom-testing-library/issues/830`,
121147
);
122148
copyStackTraceIfNeeded(error, stackTraceError);
123-
return reject(error);
149+
return onDone({ type: 'error', error });
124150
} else {
125151
return checkExpectation();
126152
}
@@ -176,13 +202,19 @@ function waitForInternal<T>(
176202
error = new Error('Timed out in waitFor.');
177203
copyStackTraceIfNeeded(error, stackTraceError);
178204
}
205+
206+
let errorForRejection: unknown = error;
179207
if (typeof onTimeout === 'function') {
180-
const result = onTimeout(error);
181-
if (result) {
182-
error = result;
208+
try {
209+
const result = onTimeout(error);
210+
if (result) {
211+
errorForRejection = result;
212+
}
213+
} catch (onTimeoutError) {
214+
errorForRejection = onTimeoutError;
183215
}
184216
}
185-
onDone({ type: 'error', error });
217+
onDone({ type: 'error', error: errorForRejection });
186218
}
187219
});
188220
}

0 commit comments

Comments
 (0)