@samitouri / QOS-React-2 / commits / 268a3f60df

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.

Andrew Clark committed Mar 28, 2018 at 14:57 UTC 268a3f60dfe67c4f6439fc37b569a2d81c81a53a
8 files changed +226 -8
packages/react-native-renderer/src/ReactNativeFrameScheduling.js
+1
@@ -23,6 +23,7 @@ let frameDeadline: number = 0;
23
24 const frameDeadlineObject: Deadline = {
25 timeRemaining: () => frameDeadline - now(),
26 + didTimeout: false,
27 };
28
29 function setTimeoutCallback() {
packages/react-noop-renderer/src/ReactNoop.js
+4
@@ -308,6 +308,10 @@ function* flushUnitsOfWork(n: number): Generator<Array<mixed>, void, void> {
308 didStop = true;
309 return 0;
310 },
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,
315 });
316
317 if (yieldedValues !== null) {
packages/react-reconciler/src/ReactFiberReconciler.js
+1
@@ -37,6 +37,7 @@ if (__DEV__) {
37
38 export type Deadline = {
39 timeRemaining: () => number,
40 + didTimeout: boolean,
41 };
42
43 type OpaqueHandle = Fiber;
packages/react-test-renderer/src/ReactTestRenderer.js
+84 -8
@@ -9,6 +9,7 @@
9
10 import type {Fiber} from 'react-reconciler/src/ReactFiber';
11 import type {FiberRoot} from 'react-reconciler/src/ReactFiberRoot';
12 +import type {Deadline} from 'react-reconciler/src/ReactFiberReconciler';
13
14 import ReactFiberReconciler from 'react-reconciler';
15 import {batchedUpdates} from 'events/ReactGenericBatching';
@@ -31,6 +32,7 @@ import invariant from 'fbjs/lib/invariant';
32
33 type TestRendererOptions = {
34 createNodeMock: (element: React$Element<any>) => any,
35 + unstable_isAsync: boolean,
36 };
37
38 type ReactTestRendererJSON = {|
@@ -116,6 +118,11 @@ function removeChild(
118 parentInstance.children.splice(index, 1);
119 }
120
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 +
126 const TestRenderer = ReactFiberReconciler({
127 getRootHostContext() {
128 return emptyObject;
@@ -200,19 +207,22 @@ const TestRenderer = ReactFiberReconciler({
207 };
208 },
209
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;
216 },
217
218 cancelDeferredCallback(timeoutID: number): void {
208 - clearTimeout(timeoutID);
219 + scheduledCallback = null;
220 },
221
222 getPublicInstance,
223
224 now(): number {
214 - // Test renderer does not use expiration
215 - return 0;
225 + return currentTime;
226 },
227
228 mutation: {
@@ -603,8 +613,14 @@ function propsMatch(props: Object, filter: Object): boolean {
613 const ReactTestRendererFiber = {
614 create(element: React$Element<any>, options: TestRendererOptions) {
615 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 + }
624 }
625 let container = {
626 children: [],
@@ -613,7 +629,7 @@ const ReactTestRendererFiber = {
629 };
630 let root: FiberRoot | null = TestRenderer.createContainer(
631 container,
616 - false,
632 + isAsync,
633 false,
634 );
635 invariant(root != null, 'something went wrong');
@@ -654,6 +670,66 @@ const ReactTestRendererFiber = {
670 container = null;
671 root = null;
672 },
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 + },
733 getInstance() {
734 if (root == null || root.current == null) {
735 return null;
packages/react-test-renderer/src/__tests__/ReactTestRendererAsync-test.js new
+97
@@ -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
@@ -63,6 +63,7 @@ if (!ExecutionEnvironment.canUseDOM) {
63 timeRemaining() {
64 return Infinity;
65 },
66 + didTimeout: false,
67 });
68 });
69 };
packages/shared/forks/ReactFeatureFlags.test-renderer.js new
+36
@@ -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 = true;
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
@@ -38,6 +38,8 @@ const forks = Object.freeze({
38 return 'shared/forks/ReactFeatureFlags.native-fabric.js';
39 case 'react-reconciler/persistent':
40 return 'shared/forks/ReactFeatureFlags.persistent.js';
41 + case 'react-test-renderer':
42 + return 'shared/forks/ReactFeatureFlags.test-renderer.js';
43 default:
44 switch (bundleType) {
45 case FB_DEV: