Skip to content

Commit 2ceb1a0

Browse files
committed
feat(wait): wait will now also run your callback on DOM changes
Closes #376
1 parent 4fed5ae commit 2ceb1a0

7 files changed

+185
-147
lines changed

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,7 @@
4444
"@sheerun/mutationobserver-shim": "^0.3.2",
4545
"@types/testing-library__dom": "^6.0.0",
4646
"aria-query": "3.0.0",
47-
"pretty-format": "^24.9.0",
48-
"wait-for-expect": "^3.0.0"
47+
"pretty-format": "^24.9.0"
4948
},
5049
"devDependencies": {
5150
"@testing-library/jest-dom": "^4.1.0",

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/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+
*/

src/wait.js

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,79 @@
1-
import waitForExpect from 'wait-for-expect'
1+
import {
2+
newMutationObserver,
3+
getDocument,
4+
setImmediate,
5+
setTimeout,
6+
clearTimeout,
7+
runWithRealTimers,
8+
} from './helpers'
29
import {getConfig} from './config'
310

4-
function wait(callback = () => {}, {timeout = getConfig().asyncUtilTimeout, interval = 50} = {}) {
5-
return waitForExpect(callback, timeout, interval)
11+
function wait(
12+
callback = () => {},
13+
{
14+
container = getDocument(),
15+
timeout = getConfig().asyncUtilTimeout,
16+
interval = 50,
17+
mutationObserverOptions = {
18+
subtree: true,
19+
childList: true,
20+
attributes: true,
21+
characterData: true,
22+
},
23+
} = {},
24+
) {
25+
if (interval < 1) interval = 1
26+
const maxTries = Math.ceil(timeout / interval)
27+
let tries = 0
28+
return new Promise((resolve, reject) => {
29+
let lastError, lastTimer
30+
const overallTimeoutTimer = setTimeout(onTimeout, timeout)
31+
32+
const observer = newMutationObserver(checkCallback)
33+
runWithRealTimers(() =>
34+
observer.observe(container, mutationObserverOptions),
35+
)
36+
37+
function onDone(error, result) {
38+
clearTimeout(overallTimeoutTimer)
39+
clearTimeout(lastTimer)
40+
setImmediate(() => observer.disconnect())
41+
if (error) {
42+
reject(error)
43+
} else {
44+
resolve(result)
45+
}
46+
}
47+
48+
function checkCallback() {
49+
try {
50+
onDone(null, callback())
51+
// If `callback` throws, wait for the next mutation or timeout.
52+
} catch (error) {
53+
// Save the callback error to reject the promise with it.
54+
lastError = error
55+
}
56+
}
57+
58+
function onTimeout() {
59+
onDone(lastError || new Error('Timed out in wait.'), null)
60+
}
61+
62+
function startTimer() {
63+
lastTimer = setTimeout(() => {
64+
tries++
65+
checkCallback()
66+
if (tries > maxTries) {
67+
onTimeout()
68+
return
69+
}
70+
startTimer()
71+
}, interval)
72+
}
73+
74+
checkCallback()
75+
startTimer()
76+
})
677
}
778

879
function waitWrapper(...args) {

0 commit comments

Comments
 (0)