-
Notifications
You must be signed in to change notification settings - Fork 273
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
Flush all pending microtasks and updates before returning from waitFor #1366
Flush all pending microtasks and updates before returning from waitFor #1366
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice catch, I've had this problem in several of my tests, looking forward for this fix!
const result = await waitForInternal(expectation, optionsWithStackTrace); | ||
// drain the microtask queue before restoring the `act` environment | ||
await new Promise((resolve) => setImmediate(resolve)); | ||
return result; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can see in the reference PR that there is
if (jestFakeTimersAreEnabled()) {
jest.advanceTimersByTime(0)
}
Do you know why this is not needed here?
Did your added test failed with both fake and real timers?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did your added test failed with both fake and real timers?
Yes.
Do you know why this is not needed here?
Yes and it's an interesting story 🙂 The setImmediate
function that is being called here, it's imported from the ./helpers/timers
module, and it's the real Node.js function, not the fake one provided by Jest. Therefore, fake timers don't need to be advanced.
We could use global.setImmediate
, and then we'd need to advance the timers, otherwise the promise would never resolve. Like this:
await new Promise((resolve) => {
global.setImmediate(resolve);
if (jestFakeTimersAreEnabled()) {
jest.advanceTimersByTime(0);
}
});
But, if you really do this, the test will start failing with fake timers! The scheduled effects won't be executed. That's because there is a bug in React: facebook/react#25889. When the react-dom
module is loaded, it will save the current value of window.setImmediate
to a local variable, and will use it later to schedule effects. If your test suite calls jest.useFakeTimers()
later, that's too late. React is already using real timers.
With fake setImmediate
used in the promise above, Jest doesn't wait long enough for the real setImmediate
timer to fire. After returning from waitFor
, the effect has not been executed yet.
To conclude, the "waitFor
+ fake timers" scenario is currently broken and is not going to work reliably. I think I know how to break @testing-library/react
even with the testing-library/react-testing-library#1137 patch applied. I'll try it out.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, that's a really good catch, this setImmediate
problem explains the issue we've had in #1347, it makes sense to me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So I had a closer look at the question whether we should call jest.advanceTimersByTime(0)
and my conclusion is no, it's not needed.
Our issue is that when a component like this is rerendered:
function Comp({ text }) {
useEffect(() => {
sideEffectWith(text);
}, [text]);
return <div>{ text }</div>;
}
then React, when committing the rerender (the commitRootImpl
function), writes the new text
to the DOM (commitMutationEffects
), and schedules a callback to flush the effects (scheduleCallback(flushPassiveEffects)
).
A waitFor
like
await waitFor(() => getByText(text));
looks at the DOM, and can be satisfied and return immediately after the DOM update. But at that time, the effects are not yet flushed, the sideEffectWith
function hasn't yet been called with the new text
value. And that's the problem, both effects (mutation and passive) belong logically together.
The effects are scheduled with the unstable_scheduleCallback
function from the scheduler
package, which uses:
window.postMessage
in a real browsersetImmediate
in Node.jssetTimeout(0)
in JSDOM environment, which supports neitherwindow.MessageChannel
norsetImmediate
React Native tests usually run in jest-environment-node
, not in jest-environment-jsdom
, so the callback is scheduled with setImmediate
. So the waitFor
implementation in @testing-library/react-native
needs to wait for the next setImmediate
. And the @testing-library/react
implementation needs to wait for the next setTimeout(0)
.
Because of facebook/react#25889, the callback is always scheduled using real timers. We don't need to advance any fake timers. We're waiting solely for things scheduled in React internals, never for any user-space callback.
The extra jest.advanceTimersByTime(0)
call could even be considered a bug: if there's user-space code that schedules a fake setTimeout(0)
, then it's the user's, i.e., the test writer's, responsibility to advance the timers accordingly, whenever the user wants the timers to be advanced. The library shouldn't do it for them, at a time that's outside user's control.
Codecov ReportPatch coverage:
Additional details and impacted files@@ Coverage Diff @@
## main #1366 +/- ##
=======================================
Coverage 96.13% 96.14%
=======================================
Files 49 49
Lines 3314 3318 +4
Branches 491 492 +1
=======================================
+ Hits 3186 3190 +4
Misses 128 128
Help us with your feedback. Take ten seconds to tell us how you rate us. Have a feature suggestion? Share it here. ☔ View full report in Codecov by Sentry. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jsnajdr Awesome work and research. Thanks for contributing this fix!
@@ -2,7 +2,7 @@ import { setImmediate } from './helpers/timers'; | |||
|
|||
type Thenable<T> = { then: (callback: () => T) => unknown }; | |||
|
|||
export function flushMicroTasks<T>(): Thenable<T> { | |||
export function flushMicroTasks(): Thenable<void> { | |||
return { | |||
// using "thenable" instead of a Promise, because otherwise it breaks when | |||
// using "modern" fake timers |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a weird thenable because the then
function doesn't return anything and is therefore not chainable. Also, the setImmediate
timer starts running at the time when the promise chain is constructed, not when it runs.
flushMicroTasks().then( flushMicroTasks ).then( flushMicroTasks )
should run there setImmediate
s after each other, but instead it will crash, and even after fixing the crash, it will run all three timers in parallel.
The way how await
works doesn't expose these bugs, but outside await
, the helper won't work.
My intuition is that the really correct version will look differently 🙂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My bad, I've missed that comment.
So when used this in instead of inline Promise
it passed the test correctly. @jsnajdr would you be able to submit a PR with better implementation. You seem to now the Promise stuff very well, so help in this area would be very welcomed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jsnajdr Should the implementation be just:
import { setImmediate } from './helpers/timers';
export function flushMicroTasks() {
return new Promise((resolve) => setImmediate(resolve));
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add #1374 so that we have a proper place for discussion
….1 (#2377) [![Mend Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com) This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [@testing-library/react-native](https://callstack.github.io/react-native-testing-library) ([source](https://github.com/callstack/react-native-testing-library)) | [`12.0.0` -> `12.0.1`](https://renovatebot.com/diffs/npm/@testing-library%2freact-native/12.0.0/12.0.1) | [![age](https://badges.renovateapi.com/packages/npm/@testing-library%2freact-native/12.0.1/age-slim)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://badges.renovateapi.com/packages/npm/@testing-library%2freact-native/12.0.1/adoption-slim)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://badges.renovateapi.com/packages/npm/@testing-library%2freact-native/12.0.1/compatibility-slim/12.0.0)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://badges.renovateapi.com/packages/npm/@testing-library%2freact-native/12.0.1/confidence-slim/12.0.0)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes <details> <summary>callstack/react-native-testing-library</summary> ### [`v12.0.1`](https://github.com/callstack/react-native-testing-library/releases/tag/v12.0.1) [Compare Source](https://github.com/callstack/react-native-testing-library/compare/v12.0.0...v12.0.1) #### What's Changed ##### Bugfixes - Flush all pending microtasks and updates before returning from waitFor by [@​jsnajdr](https://github.com/jsnajdr) in [https://github.com/callstack/react-native-testing-library/pull/1366](https://github.com/callstack/react-native-testing-library/pull/1366) - Render host component name detection tree inside act to avoid timer leaks by [@​jsnajdr](https://github.com/jsnajdr) in [https://github.com/callstack/react-native-testing-library/pull/1371](https://github.com/callstack/react-native-testing-library/pull/1371) #### New Contributors 👏 - [@​jsnajdr](https://github.com/jsnajdr) made their first contributions in [https://github.com/callstack/react-native-testing-library/pull/1366](https://github.com/callstack/react-native-testing-library/pull/1366), [https://github.com/callstack/react-native-testing-library/pull/1371](https://github.com/callstack/react-native-testing-library/pull/1371) **Full Changelog**: callstack/react-native-testing-library@v12.0.0...v12.0.1 </details> --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://www.mend.io/free-developer-tools/renovate/). View repository job log [here](https://app.renovatebot.com/dashboard#github/dooboolab/react-native-iap). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNS4xNC4yIiwidXBkYXRlZEluVmVyIjoiMzUuMTQuMiJ9--> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
….1 (#2377) [![Mend Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com) This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [@testing-library/react-native](https://callstack.github.io/react-native-testing-library) ([source](https://github.com/callstack/react-native-testing-library)) | [`12.0.0` -> `12.0.1`](https://renovatebot.com/diffs/npm/@testing-library%2freact-native/12.0.0/12.0.1) | [![age](https://badges.renovateapi.com/packages/npm/@testing-library%2freact-native/12.0.1/age-slim)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://badges.renovateapi.com/packages/npm/@testing-library%2freact-native/12.0.1/adoption-slim)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://badges.renovateapi.com/packages/npm/@testing-library%2freact-native/12.0.1/compatibility-slim/12.0.0)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://badges.renovateapi.com/packages/npm/@testing-library%2freact-native/12.0.1/confidence-slim/12.0.0)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes <details> <summary>callstack/react-native-testing-library</summary> ### [`v12.0.1`](https://github.com/callstack/react-native-testing-library/releases/tag/v12.0.1) [Compare Source](https://github.com/callstack/react-native-testing-library/compare/v12.0.0...v12.0.1) #### What's Changed ##### Bugfixes - Flush all pending microtasks and updates before returning from waitFor by [@​jsnajdr](https://github.com/jsnajdr) in [https://github.com/callstack/react-native-testing-library/pull/1366](https://github.com/callstack/react-native-testing-library/pull/1366) - Render host component name detection tree inside act to avoid timer leaks by [@​jsnajdr](https://github.com/jsnajdr) in [https://github.com/callstack/react-native-testing-library/pull/1371](https://github.com/callstack/react-native-testing-library/pull/1371) #### New Contributors 👏 - [@​jsnajdr](https://github.com/jsnajdr) made their first contributions in [https://github.com/callstack/react-native-testing-library/pull/1366](https://github.com/callstack/react-native-testing-library/pull/1366), [https://github.com/callstack/react-native-testing-library/pull/1371](https://github.com/callstack/react-native-testing-library/pull/1371) **Full Changelog**: callstack/react-native-testing-library@v12.0.0...v12.0.1 </details> --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://www.mend.io/free-developer-tools/renovate/). View repository job log [here](https://app.renovatebot.com/dashboard#github/dooboolab/react-native-iap). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNS4xNC4yIiwidXBkYXRlZEluVmVyIjoiMzUuMTQuMiJ9--> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
It can happen that a
waitFor
call likesuccessfully returns because the
"green"
text has been rendered, but after returning, the effects or state updates scheduled by the"green"
render haven't been executed yet.That's because
waitFor
internally leaves theact
environment for a moment, lets a render to be performed and to schedule updates outside theact
queue, and then returns before these updates were flushed.I'm submitting a test that exposes this bug, using a series of cascaded effects and state updates, and also a fix.
Accidentally, @eps1lon landed almost exactly the same fix to
@testing-library/react
a month ago: testing-library/react-testing-library#1137. But I discovered the React Native bug independently, while investigating why our own tests are failing. 🙂