Skip to content

Commit 93e7674

Browse files
authored
Add unstable APIs for async rendering to test renderer (#12478)
These are based on the ReactNoop renderer, which we use to test React itself. This gives library authors (Relay, Apollo, Redux, et al.) a way to test their components for async compatibility. - Pass `unstable_isAsync` to `TestRenderer.create` to create an async renderer instance. This causes updates to be lazily flushed. - `renderer.unstable_yield` tells React to yield execution after the currently rendering component. - `renderer.unstable_flushAll` flushes all pending async work, and returns an array of yielded values. - `renderer.unstable_flushThrough` receives an array of expected values, begins rendering, and stops once those values have been yielded. It returns the array of values that are actually yielded. The user should assert that they are equal. Although we've used this pattern successfully in our own tests, I'm not sure if these are the final APIs we'll make public.
1 parent d0e329b commit 93e7674

File tree

2 files changed

+181
-8
lines changed

2 files changed

+181
-8
lines changed

src/ReactTestRenderer.js

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import type {Fiber} from 'react-reconciler/src/ReactFiber';
1111
import type {FiberRoot} from 'react-reconciler/src/ReactFiberRoot';
12+
import type {Deadline} from 'react-reconciler/src/ReactFiberReconciler';
1213

1314
import ReactFiberReconciler from 'react-reconciler';
1415
import {batchedUpdates} from 'events/ReactGenericBatching';
@@ -31,6 +32,7 @@ import invariant from 'fbjs/lib/invariant';
3132

3233
type TestRendererOptions = {
3334
createNodeMock: (element: React$Element<any>) => any,
35+
unstable_isAsync: boolean,
3436
};
3537

3638
type ReactTestRendererJSON = {|
@@ -116,6 +118,11 @@ function removeChild(
116118
parentInstance.children.splice(index, 1);
117119
}
118120

121+
// Current virtual time
122+
let currentTime: number = 0;
123+
let scheduledCallback: ((deadline: Deadline) => mixed) | null = null;
124+
let yieldedValues: Array<mixed> | null = null;
125+
119126
const TestRenderer = ReactFiberReconciler({
120127
getRootHostContext() {
121128
return emptyObject;
@@ -200,19 +207,22 @@ const TestRenderer = ReactFiberReconciler({
200207
};
201208
},
202209

203-
scheduleDeferredCallback(fn: Function): number {
204-
return setTimeout(fn, 0, {timeRemaining: Infinity});
210+
scheduleDeferredCallback(
211+
callback: (deadline: Deadline) => mixed,
212+
options?: {timeout: number},
213+
): number {
214+
scheduledCallback = callback;
215+
return 0;
205216
},
206217

207218
cancelDeferredCallback(timeoutID: number): void {
208-
clearTimeout(timeoutID);
219+
scheduledCallback = null;
209220
},
210221

211222
getPublicInstance,
212223

213224
now(): number {
214-
// Test renderer does not use expiration
215-
return 0;
225+
return currentTime;
216226
},
217227

218228
mutation: {
@@ -603,8 +613,14 @@ function propsMatch(props: Object, filter: Object): boolean {
603613
const ReactTestRendererFiber = {
604614
create(element: React$Element<any>, options: TestRendererOptions) {
605615
let createNodeMock = defaultTestOptions.createNodeMock;
606-
if (options && typeof options.createNodeMock === 'function') {
607-
createNodeMock = options.createNodeMock;
616+
let isAsync = false;
617+
if (typeof options === 'object' && options !== null) {
618+
if (typeof options.createNodeMock === 'function') {
619+
createNodeMock = options.createNodeMock;
620+
}
621+
if (options.unstable_isAsync === true) {
622+
isAsync = true;
623+
}
608624
}
609625
let container = {
610626
children: [],
@@ -613,7 +629,7 @@ const ReactTestRendererFiber = {
613629
};
614630
let root: FiberRoot | null = TestRenderer.createContainer(
615631
container,
616-
false,
632+
isAsync,
617633
false,
618634
);
619635
invariant(root != null, 'something went wrong');
@@ -654,6 +670,66 @@ const ReactTestRendererFiber = {
654670
container = null;
655671
root = null;
656672
},
673+
unstable_flushAll(): Array<mixed> {
674+
yieldedValues = null;
675+
while (scheduledCallback !== null) {
676+
const cb = scheduledCallback;
677+
scheduledCallback = null;
678+
cb({
679+
timeRemaining() {
680+
// Keep rendering until there's no more work
681+
return 999;
682+
},
683+
// React's scheduler has its own way of keeping track of expired
684+
// work and doesn't read this, so don't bother setting it to the
685+
// correct value.
686+
didTimeout: false,
687+
});
688+
}
689+
if (yieldedValues === null) {
690+
// Always return an array.
691+
return [];
692+
}
693+
return yieldedValues;
694+
},
695+
unstable_flushThrough(expectedValues: Array<mixed>): Array<mixed> {
696+
let didStop = false;
697+
yieldedValues = null;
698+
while (scheduledCallback !== null && !didStop) {
699+
const cb = scheduledCallback;
700+
scheduledCallback = null;
701+
cb({
702+
timeRemaining() {
703+
if (
704+
yieldedValues !== null &&
705+
yieldedValues.length >= expectedValues.length
706+
) {
707+
// We at least as many values as expected. Stop rendering.
708+
didStop = true;
709+
return 0;
710+
}
711+
// Keep rendering.
712+
return 999;
713+
},
714+
// React's scheduler has its own way of keeping track of expired
715+
// work and doesn't read this, so don't bother setting it to the
716+
// correct value.
717+
didTimeout: false,
718+
});
719+
}
720+
if (yieldedValues === null) {
721+
// Always return an array.
722+
return [];
723+
}
724+
return yieldedValues;
725+
},
726+
unstable_yield(value: mixed): void {
727+
if (yieldedValues === null) {
728+
yieldedValues = [value];
729+
} else {
730+
yieldedValues.push(value);
731+
}
732+
},
657733
getInstance() {
658734
if (root == null || root.current == null) {
659735
return null;
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* Copyright (c) 2013-present, Facebook, Inc.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @emails react-core
8+
* @jest-environment node
9+
*/
10+
11+
'use strict';
12+
13+
const React = require('react');
14+
const ReactTestRenderer = require('react-test-renderer');
15+
16+
describe('ReactTestRendererAsync', () => {
17+
it('flushAll flushes all work', () => {
18+
function Foo(props) {
19+
return props.children;
20+
}
21+
const renderer = ReactTestRenderer.create(<Foo>Hi</Foo>, {
22+
unstable_isAsync: true,
23+
});
24+
25+
// Before flushing, nothing has mounted.
26+
expect(renderer.toJSON()).toEqual(null);
27+
28+
// Flush initial mount.
29+
renderer.unstable_flushAll();
30+
expect(renderer.toJSON()).toEqual('Hi');
31+
32+
// Update
33+
renderer.update(<Foo>Bye</Foo>);
34+
// Not yet updated.
35+
expect(renderer.toJSON()).toEqual('Hi');
36+
// Flush update.
37+
renderer.unstable_flushAll();
38+
expect(renderer.toJSON()).toEqual('Bye');
39+
});
40+
41+
it('flushAll returns array of yielded values', () => {
42+
function Child(props) {
43+
renderer.unstable_yield(props.children);
44+
return props.children;
45+
}
46+
function Parent(props) {
47+
return (
48+
<React.Fragment>
49+
<Child>{'A:' + props.step}</Child>
50+
<Child>{'B:' + props.step}</Child>
51+
<Child>{'C:' + props.step}</Child>
52+
</React.Fragment>
53+
);
54+
}
55+
const renderer = ReactTestRenderer.create(<Parent step={1} />, {
56+
unstable_isAsync: true,
57+
});
58+
59+
expect(renderer.unstable_flushAll()).toEqual(['A:1', 'B:1', 'C:1']);
60+
expect(renderer.toJSON()).toEqual(['A:1', 'B:1', 'C:1']);
61+
62+
renderer.update(<Parent step={2} />);
63+
expect(renderer.unstable_flushAll()).toEqual(['A:2', 'B:2', 'C:2']);
64+
expect(renderer.toJSON()).toEqual(['A:2', 'B:2', 'C:2']);
65+
});
66+
67+
it('flushThrough flushes until the expected values is yielded', () => {
68+
function Child(props) {
69+
renderer.unstable_yield(props.children);
70+
return props.children;
71+
}
72+
function Parent(props) {
73+
return (
74+
<React.Fragment>
75+
<Child>{'A:' + props.step}</Child>
76+
<Child>{'B:' + props.step}</Child>
77+
<Child>{'C:' + props.step}</Child>
78+
</React.Fragment>
79+
);
80+
}
81+
const renderer = ReactTestRenderer.create(<Parent step={1} />, {
82+
unstable_isAsync: true,
83+
});
84+
85+
// Flush the first two siblings
86+
expect(renderer.unstable_flushThrough(['A:1', 'B:1'])).toEqual([
87+
'A:1',
88+
'B:1',
89+
]);
90+
// Did not commit yet.
91+
expect(renderer.toJSON()).toEqual(null);
92+
93+
// Flush the remaining work
94+
expect(renderer.unstable_flushAll()).toEqual(['C:1']);
95+
expect(renderer.toJSON()).toEqual(['A:1', 'B:1', 'C:1']);
96+
});
97+
});

0 commit comments

Comments
 (0)