Skip to content

Commit

Permalink
fix: Prevent "missing act" warning for in-flight promises
Browse files Browse the repository at this point in the history
  • Loading branch information
eps1lon committed Oct 8, 2022
1 parent 4d76a4a commit ab13864
Show file tree
Hide file tree
Showing 2 changed files with 167 additions and 63 deletions.
201 changes: 139 additions & 62 deletions src/__tests__/end-to-end.js
Original file line number Diff line number Diff line change
@@ -1,73 +1,150 @@
import * as React from 'react'
import {render, waitForElementToBeRemoved, screen, waitFor} from '../'

const fetchAMessage = () =>
new Promise(resolve => {
// we are using random timeout here to simulate a real-time example
// of an async operation calling a callback at a non-deterministic time
const randomTimeout = Math.floor(Math.random() * 100)
setTimeout(() => {
resolve({returnedMessage: 'Hello World'})
}, randomTimeout)
})

function ComponentWithLoader() {
const [state, setState] = React.useState({data: undefined, loading: true})
React.useEffect(() => {
let cancelled = false
fetchAMessage().then(data => {
if (!cancelled) {
setState({data, loading: false})
}
describe.each([
['real timers', () => jest.useRealTimers()],
['fake legacy timers', () => jest.useFakeTimers('legacy')],
['fake modern timers', () => jest.useFakeTimers('modern')],
])(
'it waits for the data to be loaded in a macrotask using %s',
(label, useTimers) => {
beforeEach(() => {
useTimers()
})

return () => {
cancelled = true
afterEach(() => {
jest.useRealTimers()
})

const fetchAMessageInAMacrotask = () =>
new Promise(resolve => {
// we are using random timeout here to simulate a real-time example
// of an async operation calling a callback at a non-deterministic time
const randomTimeout = Math.floor(Math.random() * 100)
setTimeout(() => {
resolve({returnedMessage: 'Hello World'})
}, randomTimeout)
})

function ComponentWithMacrotaskLoader() {
const [state, setState] = React.useState({data: undefined, loading: true})
React.useEffect(() => {
let cancelled = false
fetchAMessageInAMacrotask().then(data => {
if (!cancelled) {
setState({data, loading: false})
}
})

return () => {
cancelled = true
}
}, [])

if (state.loading) {
return <div>Loading...</div>
}

return (
<div data-testid="message">
Loaded this message: {state.data.returnedMessage}!
</div>
)
}
}, [])

if (state.loading) {
return <div>Loading...</div>
}
test('waitForElementToBeRemoved', async () => {
render(<ComponentWithMacrotaskLoader />)
const loading = () => screen.getByText('Loading...')
await waitForElementToBeRemoved(loading)
expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
})

test('waitFor', async () => {
render(<ComponentWithMacrotaskLoader />)
// eslint-disable-next-line testing-library/prefer-find-by -- Sir, this is a test.
await waitFor(() => screen.getByText(/Loading../))
// eslint-disable-next-line testing-library/prefer-find-by -- Sir, this is a test.
await waitFor(() => screen.getByText(/Loaded this message:/))
expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
})

return (
<div data-testid="message">
Loaded this message: {state.data.returnedMessage}!
</div>
)
}
test('findBy', async () => {
render(<ComponentWithMacrotaskLoader />)
await expect(screen.findByTestId('message')).resolves.toHaveTextContent(
/Hello World/,
)
})
},
)

describe.each([
['real timers', () => jest.useRealTimers()],
['fake legacy timers', () => jest.useFakeTimers('legacy')],
// ['real timers', () => jest.useRealTimers()],
// ['fake legacy timers', () => jest.useFakeTimers('legacy')],
['fake modern timers', () => jest.useFakeTimers('modern')],
])('it waits for the data to be loaded using %s', (label, useTimers) => {
beforeEach(() => {
useTimers()
})

afterEach(() => {
jest.useRealTimers()
})

test('waitForElementToBeRemoved', async () => {
render(<ComponentWithLoader />)
const loading = () => screen.getByText('Loading...')
await waitForElementToBeRemoved(loading)
expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
})

test('waitFor', async () => {
render(<ComponentWithLoader />)
const message = () => screen.getByText(/Loaded this message:/)
await waitFor(message)
expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
})

test('findBy', async () => {
render(<ComponentWithLoader />)
await expect(screen.findByTestId('message')).resolves.toHaveTextContent(
/Hello World/,
)
})
})
])(
'it waits for the data to be loaded in a microtask using %s',
(label, useTimers) => {
beforeEach(() => {
useTimers()
})

afterEach(() => {
jest.useRealTimers()
})

const fetchAMessageInAMicrotask = () =>
Promise.resolve({
status: 200,
json: () => Promise.resolve({title: 'Hello World'}),
})

function ComponentWithMicrotaskLoader() {
const [fetchState, setFetchState] = React.useState({fetching: true})

React.useEffect(() => {
if (fetchState.fetching) {
fetchAMessageInAMicrotask().then(res => {
return res.json().then(data => {
setFetchState({todo: data.title, fetching: false})
})
})
}
}, [fetchState])

if (fetchState.fetching) {
return <p>Loading..</p>
}

return (
<div data-testid="message">Loaded this message: {fetchState.todo}</div>
)
}

test('waitForElementToBeRemoved', async () => {
render(<ComponentWithMicrotaskLoader />)
const loading = () => screen.getByText('Loading..')
await waitForElementToBeRemoved(loading)
expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
})

test('waitFor', async () => {
render(<ComponentWithMicrotaskLoader />)
await waitFor(() => {
// eslint-disable-next-line testing-library/prefer-explicit-assert -- Sir, this is a test.
screen.getByText('Loading..')
})
await waitFor(() => {
// eslint-disable-next-line testing-library/prefer-explicit-assert -- Sir, this is a test.
screen.getByText(/Loaded this message:/)
})
expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
})

test('findBy', async () => {
render(<ComponentWithMicrotaskLoader />)
await expect(screen.findByTestId('message')).resolves.toHaveTextContent(
/Hello World/,
)
})
},
)
29 changes: 28 additions & 1 deletion src/pure.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ import act, {
} from './act-compat'
import {fireEvent} from './fire-event'

function jestFakeTimersAreEnabled() {
/* istanbul ignore else */
if (typeof jest !== 'undefined' && jest !== null) {
return (
// legacy timers
setTimeout._isMockFunction === true || // modern timers
Object.prototype.hasOwnProperty.call(setTimeout, 'clock')
)
} // istanbul ignore next

return false
}

configureDTL({
unstable_advanceTimersWrapper: cb => {
return act(cb)
Expand All @@ -23,7 +36,21 @@ configureDTL({
const previousActEnvironment = getIsReactActEnvironment()
setReactActEnvironment(false)
try {
return await cb()
const result = await cb()
// Drain microtask queue.
// Otherwise we'll restore the previous act() environment, before we resolve the `waitFor` call.
// The caller would have no chance to wrap the in-flight Promises in `act()`
await new Promise(resolve => {
setTimeout(() => {
resolve()
}, 0)

if (jestFakeTimersAreEnabled()) {
jest.advanceTimersByTime(0)
}
})

return result
} finally {
setReactActEnvironment(previousActEnvironment)
}
Expand Down

0 comments on commit ab13864

Please sign in to comment.