Join GitHub today
GitHub is home to over 31 million developers working together to host and review code, manage projects, and build software together.
Sign upexpose `TestUtils.act()` for batching actions in tests #14744
Conversation
facebook-github-bot
added
the
CLA Signed
label
Feb 1, 2019
This comment has been minimized.
This comment has been minimized.
cc @kentcdodds ^ |
This comment has been minimized.
This comment has been minimized.
moving it into TestUtils |
This comment has been minimized.
This comment has been minimized.
Possible follow up: warn if we detect setState on a Hook in jsdom environment? |
threepointone
changed the title
expose `unstable_interact` for batching actions in tests
expose `TestUtils.interact()` for batching actions in tests
Feb 1, 2019
This comment has been minimized.
This comment has been minimized.
If we're going to warn for setState on Hook outside of this thing, we should do it before release. |
This comment has been minimized.
This comment has been minimized.
Noted. I'll send a followup in a bit. |
sebmarkbage
requested changes
Feb 1, 2019
We shouldn't expose private APIs to do this in the production build since we'll have to live with those for longer. The goal is to get rid of all the other exports so it's zero private exports. We only get there if we stop going the other direction. Let's just use the hack in the test utils. We should bikeshed the name a bit more. |
function batchedInteraction(callback: () => void) { | ||
batchedUpdates(callback); | ||
flushPassiveEffects(); | ||
} |
This comment has been minimized.
This comment has been minimized.
sebmarkbage
Feb 1, 2019
Member
This function should move to ReactTestUtils and instead using the ReactDOM.render(null...)
trick to flush the passive effects. That way we don't have to add any unnecessary invasive APIs to the production ReactDOM
. We can add private APIs once we have the new bundle that is exclusively for testing.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
I think this looks good. We'll probably just re-export the
I'm not sure I understand this. |
This comment has been minimized.
This comment has been minimized.
strong agree. it felt wrong right there. some options - |
This comment has been minimized.
This comment has been minimized.
We want to add a warning when you In fact this is already a problem in classes. |
This comment has been minimized.
This comment has been minimized.
We shouldn't warn in all jsdom environments. Only test ones, such as jest. Sometimes jsdom can be used in some other esoteric use cases - e.g. to server render webcomponents. |
This comment has been minimized.
This comment has been minimized.
@threepointone Instead of detecting if we're inside "interact()", we can detect if we're batching updates or in concurrent mode. I.e. if we're in sync mode. It's unfortunate because we won't warn if you use |
This comment has been minimized.
This comment has been minimized.
This makes sense. So correct me if I'm wrong but this basically means that if you interact with a component in any way that calls a state updater, you'll get a warning if it wasn't done within an |
This comment has been minimized.
This comment has been minimized.
This is tricky because we don't have a way to detect. We can detect Jest by We could check |
This comment has been minimized.
This comment has been minimized.
I suppose any jsdom server renderer should be using batchedUpdates anyway. |
sebmarkbage
reviewed
Feb 1, 2019
@@ -380,6 +380,11 @@ const ReactTestUtils = { | |||
|
|||
Simulate: null, | |||
SimulateNative: {}, | |||
|
|||
interact(callback: () => void) { |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
There is precedence in the It's also short so it's not too bothersome to write all the time in all your tests. I think it's a feature that it is non-descriptive about what actually this models. It's kind of a frame boundary but it's not always because they can also flush early. Event sequences move around a lot in various heuristics/polyfills/spec changes. So there isn't a clear semantic other than there is a bunch of work. |
sebmarkbage
reviewed
Feb 1, 2019
|
||
interact(callback: () => void) { | ||
ReactDOM.unstable_batchedUpdates(callback); | ||
ReactDOM.render(null, document.createElement('div')); |
This comment has been minimized.
This comment has been minimized.
sebmarkbage
Feb 1, 2019
Member
Can we reuse a single node? Maybe put an inline React element instead so we don't bail out.
This comment has been minimized.
This comment has been minimized.
I believe that in the nested case, these will flush at the outer |
threepointone
changed the title
expose `TestUtils.interact()` for batching actions in tests
expose `TestUtils.act()` for batching actions in tests
Feb 1, 2019
This comment has been minimized.
This comment has been minimized.
Canonical jsdom detection jsdom/jsdom#1537 (comment) |
This comment has been minimized.
This comment has been minimized.
Question - Assuming the warning exists, how would I get this test to pass? it('lets a ticker update', () => {
function App(){
let [toggle, setToggle] = useState(0)
useEffect(() => {
let timeout = setTimeout(() => {
setToggle(1)
}, 200);
return () => clearTimeout(timeout)
})
return toggle
}
const el = document.createElement('div')
act(() => {
ReactDOM.render(<App />, el);
})
jest.advanceTimersByTime(250); // this warns!!!
expect(el.innerHTML).toBe("1")
}) for context - I wrote |
This comment has been minimized.
This comment has been minimized.
one workaround is to isolate the render call and the timer advance, so this passes the test - act(() => {
ReactDOM.render(<App />, container);
});
act(() => {
jest.advanceTimersByTime(250); // doesn't warn
})
expect(container.innerHTML).toBe('1'); bit annoying though. another alternative, also annoying - act(() => {
act(()=> {
ReactDOM.render(<App />, container);
})
jest.advanceTimersByTime(250); // this warns!!!
}); (putting them both in the same act call doesn't work, since the effect wouldn't have fired yet) |
This comment has been minimized.
This comment has been minimized.
Good point @threepointone. I can see how this complicates the testing story quite a bit. Even experienced React engineers will struggle with writing tests for effects like this |
if (isBatchingUpdates === false) { | ||
warningWithoutStack( | ||
false, | ||
'It looks like you are in a test environment, trying to ' + |
This comment has been minimized.
This comment has been minimized.
gaearon
Feb 5, 2019
Member
We'll need to remember to update the wording, e.g. "UI" not "ui", and also it's not clear where to import "act" from. It's also not clear what "unexpected UI in testing" means.
if (__DEV__) { | ||
warningWithoutStack( | ||
false, | ||
'Do not await an act(...) call, it is not a promise', |
This comment has been minimized.
This comment has been minimized.
gaearon
Feb 5, 2019
Member
Nit: Capital Promise. Also probably you mean "it does not return a Promise".
if (typeof result.then === 'function') { | ||
addendum = | ||
'\n\nIt looks like you wrote act(async () => ...) or returned a Promise. ' + | ||
'Do not write async logic inside act(...)\n'; |
This comment has been minimized.
This comment has been minimized.
gaearon
Feb 5, 2019
Member
Missing period at the end of the sentence.
Also maybe "Putting asynchronous logic inside act() is not yet supported".
This comment has been minimized.
This comment has been minimized.
Jessidhia
Feb 5, 2019
Contributor
If you don't make it return a Promise
now, you can't make it accept an async
function later.
Or, well, you can, but it'll be a breaking change.
This comment has been minimized.
This comment has been minimized.
gaearon
Feb 5, 2019
Member
I think it's the other way around. If we don't make it return a thenable now, people can write await act()
without realizing they're awaiting undefined. Then making it actually return a Promise would be a breaking change.
By warning on await
now we're effectively enforcing that you don't depend on what it returns. Therefore we can later make it actually return a Promise in some cases.
threepointone
added some commits
Feb 5, 2019
sebmarkbage
approved these changes
Feb 5, 2019
Jessidhia
reviewed
Feb 5, 2019
|
||
function flushPassiveEffects() { | ||
// Trick to flush passive effects without exposing an internal API: | ||
// Create a throwaway root and schedule a dummy update on it. |
This comment has been minimized.
This comment has been minimized.
Jessidhia
Feb 5, 2019
Contributor
🤯
This will work for passive effects but we'll probably need something else for ConcurrentMode.
I guess that's where the potential Promise-based API change would come in.
threepointone
added some commits
Feb 5, 2019
threepointone
merged commit 267ed98
into
facebook:master
Feb 5, 2019
1 check passed
This comment has been minimized.
This comment has been minimized.
In the future, I'm going to subscribe to all your PRs. Watching the commit messages come in is great fun |
threepointone
referenced this pull request
Feb 5, 2019
Merged
[TestUtils.act] fix return result checking #14758
added a commit
that referenced
this pull request
Feb 5, 2019
This comment has been minimized.
This comment has been minimized.
ferdaber
commented
Feb 5, 2019
I'm super excited for this. I can't count how many times I've had to explain to my teammates to await a set timeout when testing effects, this will make it so much less work! |
threepointone
reviewed
Feb 6, 2019
]); | ||
|
||
act(() => { | ||
expect(ReactNoop.flush()).toEqual([ |
This comment has been minimized.
This comment has been minimized.
bot
pushed a commit
to SimenB/react
that referenced
this pull request
Feb 6, 2019
bot
pushed a commit
to SimenB/react
that referenced
this pull request
Feb 6, 2019
bot
pushed a commit
to chojar/react
that referenced
this pull request
Feb 6, 2019
This comment has been minimized.
This comment has been minimized.
willdurand
commented
Feb 6, 2019
A side-effect of this patch is that it broke one of our test cases that has the Because the stub element is initialized globally using
|
This comment has been minimized.
This comment has been minimized.
sakshigupta
commented
Feb 6, 2019
I am also facing the same issue, any fix for this? |
This comment has been minimized.
This comment has been minimized.
Very dirty hack around it to get the But this may possibly crash elsewhere. |
This comment has been minimized.
This comment has been minimized.
Thanks for pointing this out, @sakshigupta. Sunil is going to look into it shortly. |
added a commit
to facebook/flow
that referenced
this pull request
Feb 6, 2019
added a commit
to Kiku-git/react
that referenced
this pull request
Feb 10, 2019
added a commit
to Kiku-git/react
that referenced
this pull request
Feb 10, 2019
threepointone
referenced this pull request
Feb 15, 2019
Open
Unhelpful warning for `act` for react-dom@16.8 #14769
This comment has been minimized.
This comment has been minimized.
I'm confused about this, especially the word 'before', for example:
When execute Or your suppose is based on concurrent mode? |
This comment has been minimized.
This comment has been minimized.
I think that was kind of a mixup. The current guarantees are, AFAIK:
What |
threepointone commentedFeb 1, 2019
•
edited by gaearon
act()
for testing react componentsReact, like other libraries, doesn't guarantee synchronous ordering and execution of it's own work. eg - calling
this.setState()
inside a class doesn't actually set the component state immediately. In fact, it might not even update the state of the component in the same 'tick'. This isn't a problem when it comes to 'users'; they interact with React surfaces asynchronously, giving react and components plenty of time to 'resolve' to particular states.However, tests are unique in that people write code, usually sequential, to interact with React components, and some assumptions they make won't hold true. Consider - with React's new
useEffect
hook, a component can run a side effect as soon as it 'starts up'. As a contrived example -Let's say you write a test for it like so -
The test would fail, which seems counterintuitive at first. But the docs explain it - "The function passed to useEffect will run after the render is committed to the screen." So while the effect has been queued into it's scheduler, it's up to React to decide when to run it. React only guarantees that it'll be run before the browser has reflected changes made to the dom to the user (ie - before the browser has 'painted' the screen)
You may be tempted to refactor this like so -
This would "work" in that your test would pass, but that's because you've explicitly using a render blocking effect where it possibly wasn't required. This is bad for a number of reasons, but in this context, it's bad because we're changing product behavior just to fix a test.
What can we do better?
Well, React could expose a helper, let's call it
act
, that guarantees the sequential execution of it's update queue. Let's rewrite the test -Note - React still doesn't synchronously execute the effect (so you still can't put your
expect
statements insideact
), but it does guarantee to execute all enqueued effects right afteract
is called.This is a nice mental model for separation of concerns when testing components - "React, here's a batch of code I'd like you to run at one go", followed by more code to test what React has actually 'done'.