Skip to content

Commit 6105400

Browse files
committed
feat(wait): wait will now also run your callback on DOM changes
Closes #376
1 parent 6084f53 commit 6105400

File tree

8 files changed

+186
-148
lines changed

8 files changed

+186
-148
lines changed

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,7 @@
4545
"@types/testing-library__dom": "^6.12.1",
4646
"aria-query": "^4.0.2",
4747
"dom-accessibility-api": "^0.3.0",
48-
"pretty-format": "^25.1.0",
49-
"wait-for-expect": "^3.0.2"
48+
"pretty-format": "^25.1.0"
5049
},
5150
"devDependencies": {
5251
"@testing-library/jest-dom": "^5.1.1",

src/__tests__/fake-timers.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ jest.useFakeTimers()
2424
jest.resetModules()
2525

2626
const {
27+
wait,
2728
waitForElement,
2829
waitForDomChange,
2930
waitForElementToBeRemoved,
@@ -42,6 +43,15 @@ test('waitForElementToBeRemoved: times out after 4500ms by default', () => {
4243
return promise
4344
})
4445

46+
test('wait: can time out', async () => {
47+
const promise = wait(() => {
48+
// eslint-disable-next-line no-throw-literal
49+
throw undefined
50+
})
51+
jest.advanceTimersByTime(4600)
52+
await expect(promise).rejects.toThrow(/timed out/i)
53+
})
54+
4555
test('waitForElement: can time out', async () => {
4656
const promise = waitForElement(() => {})
4757
jest.advanceTimersByTime(4600)
@@ -85,3 +95,45 @@ test('waitForDomChange: can specify our own timeout time', async () => {
8595
// timed out
8696
await expect(promise).rejects.toThrow(/timed out/i)
8797
})
98+
99+
test('wait: ensures the interval is greater than 0', async () => {
100+
// Arrange
101+
const spy = jest.fn()
102+
spy.mockImplementationOnce(() => {
103+
throw new Error('first time does not work')
104+
})
105+
const promise = wait(spy, {interval: 0})
106+
expect(spy).toHaveBeenCalledTimes(1)
107+
spy.mockClear()
108+
109+
// Act
110+
// this line will throw an error if wait does not make the interval 1 instead of 0
111+
// which is why it does that!
112+
jest.advanceTimersByTime(0)
113+
114+
// Assert
115+
expect(spy).toHaveBeenCalledTimes(0)
116+
spy.mockImplementationOnce(() => 'second time does work')
117+
118+
// Act
119+
jest.advanceTimersByTime(1)
120+
await promise
121+
122+
// Assert
123+
expect(spy).toHaveBeenCalledTimes(1)
124+
})
125+
126+
test('wait: times out if it runs out of attempts', () => {
127+
const spy = jest.fn(() => {
128+
throw new Error('example error')
129+
})
130+
// there's a bug with this rule here...
131+
// eslint-disable-next-line jest/valid-expect
132+
const promise = expect(
133+
wait(spy, {interval: 1, timeout: 3}),
134+
).rejects.toThrowErrorMatchingInlineSnapshot(`"example error"`)
135+
jest.advanceTimersByTime(1)
136+
jest.advanceTimersByTime(1)
137+
jest.advanceTimersByTime(1)
138+
return promise
139+
})

src/__tests__/wait-for-element-to-be-removed.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ test('requires a function as the first parameter', () => {
4949
return expect(
5050
waitForElementToBeRemoved(),
5151
).rejects.toThrowErrorMatchingInlineSnapshot(
52-
`"waitForElementToBeRemoved requires a function as the first parameter"`,
52+
`"waitForElementToBeRemoved requires a callback as the first parameter"`,
5353
)
5454
})
5555

src/config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {prettyDOM} from './pretty-dom'
55
// './queries' are query functions.
66
let config = {
77
testIdAttribute: 'data-testid',
8-
asyncUtilTimeout: 4500,
8+
asyncUtilTimeout: 1000,
99
// this is to support React's async `act` function.
1010
// forcing react-testing-library to wrap all async functions would've been
1111
// a total nightmare (consider wrapping every findBy* query and then also

src/wait-for-dom-change.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {
88
} from './helpers'
99
import {getConfig} from './config'
1010

11+
// deprecated... TODO: remove this method. People should use wait instead
12+
// the reasoning is that waiting for just any DOM change is an implementation
13+
// detail. People should be waiting for a specific thing to change.
1114
function waitForDomChange({
1215
container = getDocument(),
1316
timeout = getConfig().asyncUtilTimeout,

src/wait-for-element-to-be-removed.js

Lines changed: 38 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,48 @@
1-
import {
2-
getDocument,
3-
newMutationObserver,
4-
setImmediate,
5-
setTimeout,
6-
clearTimeout,
7-
runWithRealTimers,
8-
} from './helpers'
9-
import {getConfig} from './config'
1+
import {wait} from './wait'
102

11-
function waitForElementToBeRemoved(
12-
callback,
13-
{
14-
container = getDocument(),
15-
timeout = getConfig().asyncUtilTimeout,
16-
mutationObserverOptions = {
17-
subtree: true,
18-
childList: true,
19-
attributes: true,
20-
characterData: true,
21-
},
22-
} = {},
23-
) {
24-
return new Promise((resolve, reject) => {
25-
if (typeof callback !== 'function') {
26-
reject(
27-
new Error(
28-
'waitForElementToBeRemoved requires a function as the first parameter',
29-
),
30-
)
31-
}
32-
const timer = setTimeout(onTimeout, timeout)
33-
const observer = newMutationObserver(onMutation)
3+
const isRemoved = result => !result || (Array.isArray(result) && !result.length)
4+
5+
async function waitForElementToBeRemoved(callback, options) {
6+
if (!callback) {
7+
return Promise.reject(
8+
new Error(
9+
'waitForElementToBeRemoved requires a callback as the first parameter',
10+
),
11+
)
12+
}
13+
14+
// Check if the element is not present synchronously,
15+
// As the name implies, waitForElementToBeRemoved should check `present` --> `removed`
16+
if (isRemoved(callback())) {
17+
throw new Error(
18+
'The callback function which was passed did not return an element or non-empty array of elements. waitForElementToBeRemoved requires that the element(s) exist before waiting for removal.',
19+
)
20+
}
3421

35-
// Check if the element is not present synchronously,
36-
// As the name waitForElementToBeRemoved should check `present` --> `removed`
22+
return wait(() => {
23+
let result
3724
try {
38-
const result = callback()
39-
if (!result || (Array.isArray(result) && !result.length)) {
40-
onDone(
41-
new Error(
42-
'The callback function which was passed did not return an element or non-empty array of elements. waitForElementToBeRemoved requires that the element(s) exist before waiting for removal.',
43-
),
44-
)
45-
} else {
46-
// Only observe for mutations only if there is element while checking synchronously
47-
runWithRealTimers(() =>
48-
observer.observe(container, mutationObserverOptions),
49-
)
50-
}
25+
result = callback()
5126
} catch (error) {
52-
onDone(error)
53-
}
54-
55-
function onDone(error, result) {
56-
clearTimeout(timer)
57-
setImmediate(() => observer.disconnect())
58-
if (error) {
59-
reject(error)
60-
} else {
61-
resolve(result)
62-
}
63-
}
64-
function onMutation() {
65-
try {
66-
const result = callback()
67-
if (!result || (Array.isArray(result) && !result.length)) {
68-
onDone(null, true)
69-
}
70-
// If `callback` returns truthy value, wait for the next mutation or timeout.
71-
} catch (error) {
72-
onDone(null, true)
27+
if (error.message && error.message.startsWith('Unable to find')) {
28+
// All of our get* queries throw an error that starts with "Unable to find"
29+
// when it fails to find an element.
30+
// TODO: make the queries throw a special kind of error
31+
// so we can be more explicit about the check.
32+
return true
7333
}
34+
throw error
7435
}
75-
function onTimeout() {
76-
onDone(new Error('Timed out in waitForElementToBeRemoved.'), null)
36+
if (!isRemoved(result)) {
37+
throw new Error('Timed out in waitForElementToBeRemoved.')
7738
}
78-
})
39+
return true
40+
}, options)
7941
}
8042

81-
function waitForElementToBeRemovedWrapper(...args) {
82-
return getConfig().asyncWrapper(() => waitForElementToBeRemoved(...args))
83-
}
43+
export {waitForElementToBeRemoved}
8444

85-
export {waitForElementToBeRemovedWrapper as waitForElementToBeRemoved}
45+
/*
46+
eslint
47+
require-await: "off"
48+
*/

src/wait-for-element.js

Lines changed: 16 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,21 @@
1-
import {
2-
newMutationObserver,
3-
getDocument,
4-
setImmediate,
5-
setTimeout,
6-
clearTimeout,
7-
runWithRealTimers,
8-
} from './helpers'
9-
import {getConfig} from './config'
1+
import {wait} from './wait'
102

11-
function waitForElement(
12-
callback,
13-
{
14-
container = getDocument(),
15-
timeout = getConfig().asyncUtilTimeout,
16-
mutationObserverOptions = {
17-
subtree: true,
18-
childList: true,
19-
attributes: true,
20-
characterData: true,
21-
},
22-
} = {},
23-
) {
24-
return new Promise((resolve, reject) => {
25-
if (typeof callback !== 'function') {
26-
reject(
27-
new Error('waitForElement requires a callback as the first parameter'),
28-
)
29-
return
3+
async function waitForElement(callback, options) {
4+
if (!callback) {
5+
throw new Error('waitForElement requires a callback as the first parameter')
6+
}
7+
return wait(() => {
8+
const result = callback()
9+
if (!result) {
10+
throw new Error('Timed out in waitForElement.')
3011
}
31-
let lastError
32-
const timer = setTimeout(onTimeout, timeout)
33-
34-
const observer = newMutationObserver(onMutation)
35-
runWithRealTimers(() =>
36-
observer.observe(container, mutationObserverOptions),
37-
)
38-
function onDone(error, result) {
39-
clearTimeout(timer)
40-
setImmediate(() => observer.disconnect())
41-
if (error) {
42-
reject(error)
43-
} else {
44-
resolve(result)
45-
}
46-
}
47-
function onMutation() {
48-
try {
49-
const result = callback()
50-
if (result) {
51-
onDone(null, result)
52-
}
53-
// If `callback` returns falsy value, wait for the next mutation or timeout.
54-
} catch (error) {
55-
// Save the callback error to reject the promise with it.
56-
lastError = error
57-
// If `callback` throws an error, wait for the next mutation or timeout.
58-
}
59-
}
60-
function onTimeout() {
61-
onDone(lastError || new Error('Timed out in waitForElement.'), null)
62-
}
63-
onMutation()
64-
})
12+
return result
13+
}, options)
6514
}
6615

67-
function waitForElementWrapper(...args) {
68-
return getConfig().asyncWrapper(() => waitForElement(...args))
69-
}
16+
export {waitForElement}
7017

71-
export {waitForElementWrapper as waitForElement}
18+
/*
19+
eslint
20+
require-await: "off"
21+
*/

0 commit comments

Comments
 (0)