Skip to content
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

Merged
merged 5 commits into from
Mar 23, 2023

Conversation

jsnajdr
Copy link
Contributor

@jsnajdr jsnajdr commented Mar 15, 2023

It can happen that a waitFor call like

await waitFor(() => getByText('green'));

successfully 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 the act environment for a moment, lets a render to be performed and to schedule updates outside the act 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. 🙂

Copy link
Collaborator

@AugustinLF AugustinLF left a 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;
Copy link
Collaborator

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?

Copy link
Contributor Author

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.

Copy link
Collaborator

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.

Copy link
Contributor Author

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 browser
  • setImmediate in Node.js
  • setTimeout(0) in JSDOM environment, which supports neither window.MessageChannel nor setImmediate

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
Copy link

codecov bot commented Mar 23, 2023

Codecov Report

Patch coverage: 100.00% and no project coverage change.

Comparison is base (f5fc6d4) 96.13% compared to head (ac9597b) 96.14%.

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           
Impacted Files Coverage Δ
src/flushMicroTasks.ts 100.00% <100.00%> (ø)
src/waitFor.ts 76.47% <100.00%> (+0.43%) ⬆️

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.
📢 Do you have feedback about the report comment? Let us know in this issue.

Copy link
Member

@mdjastrzebski mdjastrzebski left a 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!

@mdjastrzebski mdjastrzebski merged commit f79d41d into callstack:main Mar 23, 2023
@@ -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
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 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 setImmediates 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 🙂

Copy link
Member

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.

Copy link
Member

@mdjastrzebski mdjastrzebski Mar 23, 2023

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));
}

Copy link
Member

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

hyochan referenced this pull request in hyochan/react-native-iap Mar 26, 2023
….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 [@&#8203;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 [@&#8203;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 👏

- [@&#8203;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>
marzuq-adebayo-dev referenced this pull request in marzuq-adebayo-dev/React-Native-iap May 20, 2023
….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 [@&#8203;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 [@&#8203;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 👏

- [@&#8203;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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants