Skip to content

Commit

Permalink
Add unstable APIs for async rendering to test renderer (#12478)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
acdlite authored Mar 28, 2018
1 parent d0e329b commit 93e7674
Show file tree
Hide file tree
Showing 2 changed files with 181 additions and 8 deletions.
92 changes: 84 additions & 8 deletions src/ReactTestRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import type {Fiber} from 'react-reconciler/src/ReactFiber';
import type {FiberRoot} from 'react-reconciler/src/ReactFiberRoot';
import type {Deadline} from 'react-reconciler/src/ReactFiberReconciler';

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

type TestRendererOptions = {
createNodeMock: (element: React$Element<any>) => any,
unstable_isAsync: boolean,
};

type ReactTestRendererJSON = {|
Expand Down Expand Up @@ -116,6 +118,11 @@ function removeChild(
parentInstance.children.splice(index, 1);
}

// Current virtual time
let currentTime: number = 0;
let scheduledCallback: ((deadline: Deadline) => mixed) | null = null;
let yieldedValues: Array<mixed> | null = null;

const TestRenderer = ReactFiberReconciler({
getRootHostContext() {
return emptyObject;
Expand Down Expand Up @@ -200,19 +207,22 @@ const TestRenderer = ReactFiberReconciler({
};
},

scheduleDeferredCallback(fn: Function): number {
return setTimeout(fn, 0, {timeRemaining: Infinity});
scheduleDeferredCallback(
callback: (deadline: Deadline) => mixed,
options?: {timeout: number},
): number {
scheduledCallback = callback;
return 0;
},

cancelDeferredCallback(timeoutID: number): void {
clearTimeout(timeoutID);
scheduledCallback = null;
},

getPublicInstance,

now(): number {
// Test renderer does not use expiration
return 0;
return currentTime;
},

mutation: {
Expand Down Expand Up @@ -603,8 +613,14 @@ function propsMatch(props: Object, filter: Object): boolean {
const ReactTestRendererFiber = {
create(element: React$Element<any>, options: TestRendererOptions) {
let createNodeMock = defaultTestOptions.createNodeMock;
if (options && typeof options.createNodeMock === 'function') {
createNodeMock = options.createNodeMock;
let isAsync = false;
if (typeof options === 'object' && options !== null) {
if (typeof options.createNodeMock === 'function') {
createNodeMock = options.createNodeMock;
}
if (options.unstable_isAsync === true) {
isAsync = true;
}
}
let container = {
children: [],
Expand All @@ -613,7 +629,7 @@ const ReactTestRendererFiber = {
};
let root: FiberRoot | null = TestRenderer.createContainer(
container,
false,
isAsync,
false,
);
invariant(root != null, 'something went wrong');
Expand Down Expand Up @@ -654,6 +670,66 @@ const ReactTestRendererFiber = {
container = null;
root = null;
},
unstable_flushAll(): Array<mixed> {
yieldedValues = null;
while (scheduledCallback !== null) {
const cb = scheduledCallback;
scheduledCallback = null;
cb({
timeRemaining() {
// Keep rendering until there's no more work
return 999;
},
// React's scheduler has its own way of keeping track of expired
// work and doesn't read this, so don't bother setting it to the
// correct value.
didTimeout: false,
});
}
if (yieldedValues === null) {
// Always return an array.
return [];
}
return yieldedValues;
},
unstable_flushThrough(expectedValues: Array<mixed>): Array<mixed> {
let didStop = false;
yieldedValues = null;
while (scheduledCallback !== null && !didStop) {
const cb = scheduledCallback;
scheduledCallback = null;
cb({
timeRemaining() {
if (
yieldedValues !== null &&
yieldedValues.length >= expectedValues.length
) {
// We at least as many values as expected. Stop rendering.
didStop = true;
return 0;
}
// Keep rendering.
return 999;
},
// React's scheduler has its own way of keeping track of expired
// work and doesn't read this, so don't bother setting it to the
// correct value.
didTimeout: false,
});
}
if (yieldedValues === null) {
// Always return an array.
return [];
}
return yieldedValues;
},
unstable_yield(value: mixed): void {
if (yieldedValues === null) {
yieldedValues = [value];
} else {
yieldedValues.push(value);
}
},
getInstance() {
if (root == null || root.current == null) {
return null;
Expand Down
97 changes: 97 additions & 0 deletions src/__tests__/ReactTestRendererAsync-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Copyright (c) 2013-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
* @jest-environment node
*/

'use strict';

const React = require('react');
const ReactTestRenderer = require('react-test-renderer');

describe('ReactTestRendererAsync', () => {
it('flushAll flushes all work', () => {
function Foo(props) {
return props.children;
}
const renderer = ReactTestRenderer.create(<Foo>Hi</Foo>, {
unstable_isAsync: true,
});

// Before flushing, nothing has mounted.
expect(renderer.toJSON()).toEqual(null);

// Flush initial mount.
renderer.unstable_flushAll();
expect(renderer.toJSON()).toEqual('Hi');

// Update
renderer.update(<Foo>Bye</Foo>);
// Not yet updated.
expect(renderer.toJSON()).toEqual('Hi');
// Flush update.
renderer.unstable_flushAll();
expect(renderer.toJSON()).toEqual('Bye');
});

it('flushAll returns array of yielded values', () => {
function Child(props) {
renderer.unstable_yield(props.children);
return props.children;
}
function Parent(props) {
return (
<React.Fragment>
<Child>{'A:' + props.step}</Child>
<Child>{'B:' + props.step}</Child>
<Child>{'C:' + props.step}</Child>
</React.Fragment>
);
}
const renderer = ReactTestRenderer.create(<Parent step={1} />, {
unstable_isAsync: true,
});

expect(renderer.unstable_flushAll()).toEqual(['A:1', 'B:1', 'C:1']);
expect(renderer.toJSON()).toEqual(['A:1', 'B:1', 'C:1']);

renderer.update(<Parent step={2} />);
expect(renderer.unstable_flushAll()).toEqual(['A:2', 'B:2', 'C:2']);
expect(renderer.toJSON()).toEqual(['A:2', 'B:2', 'C:2']);
});

it('flushThrough flushes until the expected values is yielded', () => {
function Child(props) {
renderer.unstable_yield(props.children);
return props.children;
}
function Parent(props) {
return (
<React.Fragment>
<Child>{'A:' + props.step}</Child>
<Child>{'B:' + props.step}</Child>
<Child>{'C:' + props.step}</Child>
</React.Fragment>
);
}
const renderer = ReactTestRenderer.create(<Parent step={1} />, {
unstable_isAsync: true,
});

// Flush the first two siblings
expect(renderer.unstable_flushThrough(['A:1', 'B:1'])).toEqual([
'A:1',
'B:1',
]);
// Did not commit yet.
expect(renderer.toJSON()).toEqual(null);

// Flush the remaining work
expect(renderer.unstable_flushAll()).toEqual(['C:1']);
expect(renderer.toJSON()).toEqual(['A:1', 'B:1', 'C:1']);
});
});

0 comments on commit 93e7674

Please sign in to comment.