Skip to content

Commit 9aaf715

Browse files
KevinBonKevinBonHubspotSebastian Silbermann
authored
fix: Stop calling waitFor callback after timeout (#1271)
* prevent waitFor callback racing condition * change logic * adapt the waitFor resolve test to make it more readable * surface `timeout` + waitFor `callback` calls * adding comment * adding reproductible test * robuster assertion for not calling callback again --------- Co-authored-by: Kevin Bon <kbon@hubspot.com> Co-authored-by: Sebastian Silbermann <sebastian.silbermann@klarna.com>
1 parent a7b7252 commit 9aaf715

File tree

2 files changed

+109
-20
lines changed

2 files changed

+109
-20
lines changed

src/__tests__/wait-for.js

+105-16
Original file line numberDiff line numberDiff line change
@@ -292,12 +292,11 @@ test('the real timers => fake timers error shows the original stack trace when c
292292

293293
test('does not work after it resolves', async () => {
294294
jest.useFakeTimers('modern')
295-
let context = 'initial'
295+
const contextStack = []
296296
configure({
297297
// @testing-library/react usage to ensure `IS_REACT_ACT_ENVIRONMENT` is set when acting.
298298
unstable_advanceTimersWrapper: callback => {
299-
const originalContext = context
300-
context = 'act'
299+
contextStack.push('act:start')
301300
try {
302301
const result = callback()
303302
// eslint-disable-next-line jest/no-if, jest/no-conditional-in-test -- false-positive
@@ -307,54 +306,144 @@ test('does not work after it resolves', async () => {
307306
then: (resolve, reject) => {
308307
thenable.then(
309308
returnValue => {
310-
context = originalContext
309+
contextStack.push('act:end')
311310
resolve(returnValue)
312311
},
313312
error => {
314-
context = originalContext
313+
contextStack.push('act:end')
315314
reject(error)
316315
},
317316
)
318317
},
319318
}
320319
} else {
321-
context = originalContext
320+
contextStack.push('act:end')
322321
return undefined
323322
}
324323
} catch {
325-
context = originalContext
324+
contextStack.push('act:end')
326325
return undefined
327326
}
328327
},
329328
asyncWrapper: async callback => {
330-
const originalContext = context
331-
context = 'no-act'
329+
contextStack.push('no-act:start')
332330
try {
333331
await callback()
334332
} finally {
335-
context = originalContext
333+
contextStack.push('no-act:end')
336334
}
337335
},
338336
})
339337

340-
let data = null
338+
let timeoutResolved = false
341339
setTimeout(() => {
342-
data = 'resolved'
340+
contextStack.push('timeout')
341+
timeoutResolved = true
343342
}, 100)
344343

344+
contextStack.push('waitFor:start')
345345
await waitFor(
346346
() => {
347+
contextStack.push('callback')
347348
// eslint-disable-next-line jest/no-conditional-in-test -- false-positive
348-
if (data === null) {
349+
if (!timeoutResolved) {
349350
throw new Error('not found')
350351
}
351352
},
352353
{interval: 50},
353354
)
354-
355-
expect(context).toEqual('initial')
355+
contextStack.push('waitFor:end')
356+
357+
expect(contextStack).toMatchInlineSnapshot(`
358+
[
359+
waitFor:start,
360+
no-act:start,
361+
callback,
362+
act:start,
363+
act:end,
364+
callback,
365+
act:start,
366+
timeout,
367+
act:end,
368+
callback,
369+
no-act:end,
370+
waitFor:end,
371+
]
372+
`)
356373

357374
await Promise.resolve()
358375

359-
expect(context).toEqual('initial')
376+
// The context call stack should not change
377+
expect(contextStack).toMatchInlineSnapshot(`
378+
[
379+
waitFor:start,
380+
no-act:start,
381+
callback,
382+
act:start,
383+
act:end,
384+
callback,
385+
act:start,
386+
timeout,
387+
act:end,
388+
callback,
389+
no-act:end,
390+
waitFor:end,
391+
]
392+
`)
393+
})
394+
395+
test(`when fake timer is installed, on waitFor timeout, it doesn't call the callback afterward`, async () => {
396+
jest.useFakeTimers('modern')
397+
398+
configure({
399+
// @testing-library/react usage to ensure `IS_REACT_ACT_ENVIRONMENT` is set when acting.
400+
unstable_advanceTimersWrapper: callback => {
401+
try {
402+
const result = callback()
403+
// eslint-disable-next-line jest/no-if, jest/no-conditional-in-test -- false-positive
404+
if (typeof result?.then === 'function') {
405+
const thenable = result
406+
return {
407+
then: (resolve, reject) => {
408+
thenable.then(
409+
returnValue => {
410+
resolve(returnValue)
411+
},
412+
error => {
413+
reject(error)
414+
},
415+
)
416+
},
417+
}
418+
} else {
419+
return undefined
420+
}
421+
} catch {
422+
return undefined
423+
}
424+
},
425+
asyncWrapper: async callback => {
426+
try {
427+
await callback()
428+
} finally {
429+
/* eslint no-empty: "off" */
430+
}
431+
},
432+
})
433+
434+
const callback = jest.fn(() => {
435+
throw new Error('We want to timeout')
436+
})
437+
const interval = 50
438+
439+
await expect(() =>
440+
waitFor(callback, {interval, timeout: 100}),
441+
).rejects.toThrow()
442+
expect(callback).toHaveBeenCalledWith()
443+
444+
callback.mockClear()
445+
446+
await jest.advanceTimersByTimeAsync(interval)
447+
448+
expect(callback).not.toHaveBeenCalledWith()
360449
})

src/wait-for.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -81,15 +81,15 @@ function waitFor(
8181
jest.advanceTimersByTime(interval)
8282
})
8383

84+
// Could have timed-out
85+
if (finished) {
86+
break
87+
}
8488
// It's really important that checkCallback is run *before* we flush
8589
// in-flight promises. To be honest, I'm not sure why, and I can't quite
8690
// think of a way to reproduce the problem in a test, but I spent
8791
// an entire day banging my head against a wall on this.
8892
checkCallback()
89-
90-
if (finished) {
91-
break
92-
}
9393
}
9494
} else {
9595
try {

0 commit comments

Comments
 (0)