Skip to content

Commit 15adc46

Browse files
committed
Add unstable APIs for async renderering to test renderer
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 90c41a2 commit 15adc46

File tree

8 files changed

+226
-8
lines changed

8 files changed

+226
-8
lines changed

packages/react-native-renderer/src/ReactNativeFrameScheduling.js

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ let frameDeadline: number = 0;
2323

2424
const frameDeadlineObject: Deadline = {
2525
timeRemaining: () => frameDeadline - now(),
26+
didTimeout: false,
2627
};
2728

2829
function setTimeoutCallback() {

packages/react-noop-renderer/src/ReactNoop.js

+4
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,10 @@ function* flushUnitsOfWork(n: number): Generator<Array<mixed>, void, void> {
308308
didStop = true;
309309
return 0;
310310
},
311+
// React's scheduler has its own way of keeping track of expired
312+
// work and doesn't read this, so don't bother setting it to the
313+
// correct value.
314+
didTimeout: false,
311315
});
312316

313317
if (yieldedValues !== null) {

packages/react-reconciler/src/ReactFiberReconciler.js

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ if (__DEV__) {
3737

3838
export type Deadline = {
3939
timeRemaining: () => number,
40+
didTimeout: boolean,
4041
};
4142

4243
type OpaqueHandle = Fiber;

packages/react-test-renderer/src/ReactTestRenderer.js

+84-8
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;
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+
});

packages/shared/ReactDOMFrameScheduling.js

+1
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ if (!ExecutionEnvironment.canUseDOM) {
6363
timeRemaining() {
6464
return Infinity;
6565
},
66+
didTimeout: false,
6667
});
6768
});
6869
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
* @flow
8+
*/
9+
10+
import invariant from 'fbjs/lib/invariant';
11+
12+
import typeof * as FeatureFlagsType from 'shared/ReactFeatureFlags';
13+
import typeof * as PersistentFeatureFlagsType from './ReactFeatureFlags.persistent';
14+
15+
export const debugRenderPhaseSideEffects = false;
16+
export const debugRenderPhaseSideEffectsForStrictMode = false;
17+
export const enableCreateRoot = false;
18+
export const enableUserTimingAPI = __DEV__;
19+
export const enableGetDerivedStateFromCatch = false;
20+
export const warnAboutDeprecatedLifecycles = false;
21+
export const replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
22+
export const enableMutatingReconciler = false;
23+
export const enableNoopReconciler = false;
24+
export const enablePersistentReconciler = false;
25+
export const alwaysUseRequestIdleCallbackPolyfill = false;
26+
27+
// Only used in www builds.
28+
export function addUserTimingListener() {
29+
invariant(false, 'Not implemented.');
30+
}
31+
32+
// Flow magic to verify the exports of this file match the original version.
33+
// eslint-disable-next-line no-unused-vars
34+
type Check<_X, Y: _X, X: Y = _X> = null;
35+
// eslint-disable-next-line no-unused-expressions
36+
(null: Check<PersistentFeatureFlagsType, FeatureFlagsType>);

scripts/rollup/forks.js

+2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ const forks = Object.freeze({
3838
return 'shared/forks/ReactFeatureFlags.native-fabric.js';
3939
case 'react-reconciler/persistent':
4040
return 'shared/forks/ReactFeatureFlags.persistent.js';
41+
case 'react-test-renderer':
42+
return 'shared/forks/ReactFeatureFlags.test-renderer.js';
4143
default:
4244
switch (bundleType) {
4345
case FB_DEV:

0 commit comments

Comments
 (0)