Skip to content

fix: Stop calling waitFor callback after timeout #1271

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jan 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 105 additions & 16 deletions src/__tests__/wait-for.js
Original file line number Diff line number Diff line change
Expand Up @@ -292,12 +292,11 @@ test('the real timers => fake timers error shows the original stack trace when c

test('does not work after it resolves', async () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made some changes on this test to help understand the outcome: everything that starts needs to end

jest.useFakeTimers('modern')
let context = 'initial'
const contextStack = []
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or

Suggested change
const contextStack = []
const contextCallStack = []

configure({
// @testing-library/react usage to ensure `IS_REACT_ACT_ENVIRONMENT` is set when acting.
unstable_advanceTimersWrapper: callback => {
const originalContext = context
context = 'act'
contextStack.push('act:start')
try {
const result = callback()
// eslint-disable-next-line jest/no-if, jest/no-conditional-in-test -- false-positive
Expand All @@ -307,54 +306,144 @@ test('does not work after it resolves', async () => {
then: (resolve, reject) => {
thenable.then(
returnValue => {
context = originalContext
contextStack.push('act:end')
resolve(returnValue)
},
error => {
context = originalContext
contextStack.push('act:end')
reject(error)
},
)
},
}
} else {
context = originalContext
contextStack.push('act:end')
return undefined
}
} catch {
context = originalContext
contextStack.push('act:end')
return undefined
}
},
asyncWrapper: async callback => {
const originalContext = context
context = 'no-act'
contextStack.push('no-act:start')
try {
await callback()
} finally {
context = originalContext
contextStack.push('no-act:end')
}
},
})

let data = null
let timeoutResolved = false
setTimeout(() => {
data = 'resolved'
contextStack.push('timeout')
timeoutResolved = true
}, 100)

contextStack.push('waitFor:start')
await waitFor(
() => {
contextStack.push('callback')
// eslint-disable-next-line jest/no-conditional-in-test -- false-positive
if (data === null) {
if (!timeoutResolved) {
throw new Error('not found')
}
},
{interval: 50},
)

expect(context).toEqual('initial')
contextStack.push('waitFor:end')

expect(contextStack).toMatchInlineSnapshot(`
[
waitFor:start,
no-act:start,
callback,
act:start,
act:end,
callback,
act:start,
timeout,
act:end,
callback,
no-act:end,
waitFor:end,
]
`)

await Promise.resolve()

expect(context).toEqual('initial')
// The context call stack should not change
expect(contextStack).toMatchInlineSnapshot(`
[
waitFor:start,
no-act:start,
callback,
act:start,
act:end,
callback,
act:start,
timeout,
act:end,
callback,
no-act:end,
waitFor:end,
]
`)
})

test(`when fake timer is installed, on waitFor timeout, it doesn't call the callback afterward`, async () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test ensures that the fix is working

jest.useFakeTimers('modern')

configure({
// @testing-library/react usage to ensure `IS_REACT_ACT_ENVIRONMENT` is set when acting.
unstable_advanceTimersWrapper: callback => {
try {
const result = callback()
// eslint-disable-next-line jest/no-if, jest/no-conditional-in-test -- false-positive
if (typeof result?.then === 'function') {
const thenable = result
return {
then: (resolve, reject) => {
thenable.then(
returnValue => {
resolve(returnValue)
},
error => {
reject(error)
},
)
},
}
} else {
return undefined
}
} catch {
return undefined
}
},
asyncWrapper: async callback => {
try {
await callback()
} finally {
/* eslint no-empty: "off" */
}
},
})

const callback = jest.fn(() => {
throw new Error('We want to timeout')
})
const interval = 50

await expect(() =>
waitFor(callback, {interval, timeout: 100}),
).rejects.toThrow()
expect(callback).toHaveBeenCalledWith()

callback.mockClear()

await jest.advanceTimersByTimeAsync(interval)

expect(callback).not.toHaveBeenCalledWith()
})
8 changes: 4 additions & 4 deletions src/wait-for.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,15 @@ function waitFor(
jest.advanceTimersByTime(interval)
})

// Could have timed-out
if (finished) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this factoring, I wonder if we could simplify further by moving checkCallback before the advanceTimers. That way the loop condition makes sure we exit as soon as we're finished. And we can remove another checkCallback on L54.

At this point I would remove the "It's really important that checkCallback [...]" comment. We might have fixed the issue along the way. And since I'd rather ship this with the next major, we have a good opportunity to resurface the bug so that we can at least link a repro. It doesn't have to be a Jest test but somehow the reordering fixed "something". That "something" needs to be linked a t least. Doesn't need to be an automated test but it needs to be something. Otherwise we're chasing ghosts.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we move checkCallback before the advanceTimers, the waitFor callback will be called twice before we advance the clock, instead of once right now.

Which will break this fix: https://github.com/testing-library/dom-testing-library/pull/1073/files

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the waitFor callback will be called twice before we advance the clock, instead of once right now.

but not if we remove the checkCallback call earlier from L54, no?

Copy link
Contributor Author

@KevinBon KevinBon Oct 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There will be a change of behavior in

).rejects.toMatchInlineSnapshot(`[Error: always throws]`)
, for a timeout smaller than the interval, it will instead be rejected with [Error: Timed out in waitFor.]

break
}
// It's really important that checkCallback is run *before* we flush
// in-flight promises. To be honest, I'm not sure why, and I can't quite
// think of a way to reproduce the problem in a test, but I spent
// an entire day banging my head against a wall on this.
checkCallback()

if (finished) {
break
}
}
} else {
try {
Expand Down