Skip to content
This repository was archived by the owner on Jul 30, 2020. It is now read-only.

Commit b2fb9cc

Browse files
committed
feat: add support for async act
1 parent 8939db0 commit b2fb9cc

12 files changed

+267
-21
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"license": "MIT",
3535
"dependencies": {
3636
"pretty-format": "^24.5.0",
37-
"react-test-renderer": "^16.8.5",
37+
"react-test-renderer": "^16.9.0-alpha.0",
3838
"wait-for-expect": "^1.1.1"
3939
},
4040
"devDependencies": {
@@ -51,7 +51,7 @@
5151
"metro-react-native-babel-preset": "^0.52.0",
5252
"prettier": "^1.16.4",
5353
"pretty-quick": "^1.10.0",
54-
"react": "^16.8.5",
54+
"react": "^16.9.0-alpha.0",
5555
"react-intl": "^2.8.0",
5656
"react-intl-native": "^2.1.2",
5757
"react-native": "^0.59.0",

src/__tests__/events.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ test('calling a handler if a Touchable is disabled throws', () => {
6767
const { getByText } = render(
6868
<TouchableHighlight disabled onPress={jest.fn()}>
6969
<Text>touchable</Text>
70-
</TouchableHighlight>
70+
</TouchableHighlight>,
7171
);
7272
expect(() => fireEvent.press(getByText('touchable'))).toThrow();
7373
expect(handleEvent).toBeCalledTimes(0);

src/__tests__/old-act.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { asyncAct } from '../act-compat';
2+
3+
jest.mock('react-test-renderer', () => ({
4+
act: cb => {
5+
const promise = cb();
6+
return {
7+
then() {
8+
console.error('blah, do not do this');
9+
return promise;
10+
},
11+
};
12+
},
13+
}));
14+
15+
test('async act works even when the act is an old one', async () => {
16+
jest.spyOn(console, 'error').mockImplementation(() => {});
17+
const callback = jest.fn();
18+
await asyncAct(async () => {
19+
await Promise.resolve();
20+
await callback();
21+
});
22+
expect(console.error.mock.calls).toMatchInlineSnapshot(`Array []`);
23+
expect(callback).toHaveBeenCalledTimes(1);
24+
25+
// and it doesn't warn you twice
26+
callback.mockClear();
27+
console.error.mockClear();
28+
29+
await asyncAct(async () => {
30+
await Promise.resolve();
31+
await callback();
32+
});
33+
expect(console.error).toHaveBeenCalledTimes(0);
34+
expect(callback).toHaveBeenCalledTimes(1);
35+
36+
console.error.mockRestore();
37+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import React from 'react';
2+
import { View } from 'react-native';
3+
4+
import { render } from '../';
5+
import { waitForElementToBeRemoved } from '../wait-for-element-to-be-removed';
6+
7+
jest.useFakeTimers();
8+
9+
test('requires a function as the first parameter', () => {
10+
return expect(waitForElementToBeRemoved()).rejects.toThrowErrorMatchingInlineSnapshot(
11+
`"waitForElementToBeRemoved requires a callback as the first parameter"`,
12+
);
13+
});
14+
15+
test('requires an element to exist first', () => {
16+
return expect(waitForElementToBeRemoved(() => null)).rejects.toThrowErrorMatchingInlineSnapshot(
17+
`"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."`,
18+
);
19+
});
20+
21+
test('requires an unempty array of elements to exist first', () => {
22+
return expect(waitForElementToBeRemoved(() => [])).rejects.toThrowErrorMatchingInlineSnapshot(
23+
`"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."`,
24+
);
25+
});
26+
27+
test('times out after 4500ms by default', () => {
28+
const { rootInstance } = render(<View />);
29+
const promise = expect(
30+
waitForElementToBeRemoved(() => rootInstance),
31+
).rejects.toThrowErrorMatchingInlineSnapshot(`"Timed out in waitForElementToBeRemoved."`);
32+
33+
jest.advanceTimersByTime(4501);
34+
35+
return promise;
36+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React from 'react';
2+
import { View } from 'react-native';
3+
4+
import { render } from '../';
5+
import { waitForElementToBeRemoved } from '../wait-for-element-to-be-removed';
6+
7+
test('resolves only when the element is removed', async () => {
8+
class MutatedElement extends React.Component {
9+
state = {
10+
text: 'original',
11+
visible: true,
12+
};
13+
14+
componentDidMount() {
15+
// mutation
16+
this.setState({ text: 'mutated' });
17+
18+
// removal
19+
setTimeout(() => {
20+
this.setState({ visible: false });
21+
}, 100);
22+
}
23+
24+
render() {
25+
return this.state.visible ? <View testID="view">{this.state.text}</View> : null;
26+
}
27+
}
28+
29+
const { queryAllByTestId } = render(<MutatedElement />);
30+
31+
// the timeout is here for two reasons:
32+
// 1. It helps test the timeout config
33+
// 2. The element should be removed immediately
34+
// so if it doesn't in the first 100ms then we know something's wrong
35+
// so we'll fail early and not wait the full timeout
36+
await waitForElementToBeRemoved(() => queryAllByTestId('view'), { timeout: 500 });
37+
});
38+
39+
test('resolves on mutation if callback throws an error', async () => {
40+
class MutatedElement extends React.Component {
41+
state = {
42+
visible: true,
43+
};
44+
45+
componentDidMount() {
46+
setTimeout(() => {
47+
this.setState({ visible: false });
48+
});
49+
}
50+
51+
render() {
52+
return this.state.visible ? <View testID="view" /> : null;
53+
}
54+
}
55+
56+
const { getByTestId } = render(<MutatedElement />);
57+
58+
await waitForElementToBeRemoved(() => getByTestId('view'), { timeout: 100 });
59+
});

src/act-compat.js

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,42 @@
1-
import { act as trAct } from 'react-test-renderer';
1+
let reactAct;
2+
let actSupported = false;
3+
let asyncActSupported = false;
4+
5+
try {
6+
reactAct = require('react-test-renderer').act;
7+
actSupported = reactAct !== undefined;
8+
9+
const originalError = console.error;
10+
let errorCalled = false;
11+
console.error = () => {
12+
errorCalled = true;
13+
};
14+
console.error.calls = [];
15+
/* istanbul ignore next */
16+
reactAct(() => ({ then: () => {} })).then(() => {});
17+
/* istanbul ignore next */
18+
if (!errorCalled) {
19+
asyncActSupported = true;
20+
}
21+
console.error = originalError;
22+
} catch (error) {
23+
// ignore, this is to support old versions of react
24+
}
225

326
function actPolyfill(callback) {
427
callback();
528
}
629

7-
const act = trAct || actPolyfill;
30+
const act = reactAct || actPolyfill;
831

9-
function rntlAct(callback) {
10-
return act(callback);
32+
async function asyncActPolyfill(cb) {
33+
await cb();
34+
// make all effects resolve after
35+
act(() => {});
1136
}
1237

13-
export default rntlAct;
38+
// istanbul ignore next
39+
const asyncAct = asyncActSupported ? reactAct : asyncActPolyfill;
40+
41+
export default act;
42+
export { asyncAct };

src/config.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { asyncAct } from './act-compat';
2+
3+
let config = {
4+
asyncWrapper: async cb => {
5+
let result;
6+
asyncAct(() => {
7+
result = cb();
8+
});
9+
return result;
10+
},
11+
};
12+
13+
export function getConfig() {
14+
return config;
15+
}

src/index.js

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ function render(ui, { options = {}, wrapper: WrapperComponent } = {}) {
2424
rootInstance: container.root,
2525
debug: (el = container) => console.log(prettyPrint(el)),
2626
unmount: () => container.unmount(),
27-
rerender: rerenderUi => container.update(wrapUiIfNeeded(rerenderUi)),
27+
rerender: rerenderUi => {
28+
act(() => {
29+
container.update(wrapUiIfNeeded(rerenderUi));
30+
});
31+
},
2832
...getQueriesForElement(container),
2933
};
3034
}
@@ -43,17 +47,10 @@ function renderHook(callback, { initialProps, ...options } = {}) {
4347

4448
return {
4549
result,
46-
waitForNextUpdate: () =>
47-
new Promise(resolve =>
48-
act(() => {
49-
addResolver(resolve);
50-
}),
51-
),
50+
waitForNextUpdate: () => new Promise(resolve => addResolver(resolve)),
5251
rerender: (newProps = hookProps.current) => {
5352
hookProps.current = newProps;
54-
act(() => {
55-
rerenderComponent(toRender());
56-
});
53+
rerenderComponent(toRender());
5754
},
5855
unmount,
5956
};
@@ -85,6 +82,7 @@ export * from './queries';
8582
export * from './query-helpers';
8683
export * from './wait';
8784
export * from './wait-for-element';
85+
export * from './wait-for-element-to-be-removed';
8886
export { getDefaultNormalizer } from './matches';
8987

9088
export { act, fireEvent, queries, queryHelpers, render, renderHook, NativeEvent };

src/pretty-print.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ function getPrettyOutput(reactElement, maxLength, options) {
99
...options,
1010
});
1111

12-
return maxLength !== undefined && reactElement.toString().length > maxLength
12+
return maxLength !== undefined && debugContent && debugContent.toString().length > maxLength
1313
? `${debugContent.slice(0, maxLength)}...`
1414
: debugContent;
1515
}

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { getConfig } from './config';
2+
import { getSetImmediate } from './helpers';
3+
4+
function waitForElementToBeRemoved(callback, { container, interval = 50, timeout = 4500 } = {}) {
5+
return new Promise((resolve, reject) => {
6+
if (typeof callback !== 'function') {
7+
reject(new Error('waitForElementToBeRemoved requires a callback as the first parameter'));
8+
return;
9+
}
10+
const timer = setTimeout(onTimeout, timeout);
11+
let observer;
12+
13+
// Check if the element is not present
14+
/* istanbul ignore next */
15+
const result = container ? callback(container) : callback();
16+
if (!result || (Array.isArray(result) && !result.length)) {
17+
onDone(
18+
new Error(
19+
'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.',
20+
),
21+
);
22+
}
23+
24+
observer = setTimeout(onMutation);
25+
26+
function onDone(error, result) {
27+
const setImmediate = getSetImmediate();
28+
clearTimeout(timer);
29+
setImmediate(() => clearTimeout(observer));
30+
if (error) {
31+
reject(error);
32+
} else {
33+
resolve(result);
34+
}
35+
}
36+
37+
function onMutation() {
38+
try {
39+
/* istanbul ignore next */
40+
const result = container ? callback(container) : callback();
41+
if (!result || (Array.isArray(result) && !result.length)) {
42+
onDone(null, true);
43+
} else {
44+
observer = setTimeout(onMutation, interval);
45+
}
46+
} catch (error) {
47+
onDone(null, true);
48+
}
49+
}
50+
51+
function onTimeout() {
52+
onDone(new Error('Timed out in waitForElementToBeRemoved.'), null);
53+
}
54+
});
55+
}
56+
57+
function waitForElementToBeRemovedWrapper(...args) {
58+
return getConfig().asyncWrapper(() => waitForElementToBeRemoved(...args));
59+
}
60+
61+
export { waitForElementToBeRemovedWrapper as waitForElementToBeRemoved };

src/wait-for-element.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getConfig } from './config';
12
import { getSetImmediate } from './helpers';
23

34
function waitForElement(callback, { container, interval = 50, timeout = 4500 } = {}) {
@@ -40,4 +41,8 @@ function waitForElement(callback, { container, interval = 50, timeout = 4500 } =
4041
});
4142
}
4243

43-
export { waitForElement };
44+
function waitForElementWrapper(...args) {
45+
return getConfig().asyncWrapper(() => waitForElement(...args));
46+
}
47+
48+
export { waitForElementWrapper as waitForElement };

src/wait.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import waitForExpect from 'wait-for-expect';
22

3+
import { getConfig } from './config';
4+
35
function wait(callback = () => {}, { timeout = 4500, interval = 50 } = {}) {
46
return waitForExpect(callback, timeout, interval);
57
}
68

7-
export { wait };
9+
function waitWrapper(...args) {
10+
return getConfig().asyncWrapper(() => wait(...args));
11+
}
12+
13+
export { waitWrapper as wait };

0 commit comments

Comments
 (0)