From c4bab313dfdba9d1e8e8a863a5e67130095449c3 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 28 Mar 2018 13:57:50 -0700 Subject: [PATCH] Add unstable APIs for async rendering 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. --- .../src/ReactNativeFrameScheduling.js | 1 + packages/react-noop-renderer/src/ReactNoop.js | 4 + .../src/ReactFiberReconciler.js | 1 + .../src/ReactTestRenderer.js | 92 ++++++++++++++++-- .../__tests__/ReactTestRendererAsync-test.js | 97 +++++++++++++++++++ packages/shared/ReactDOMFrameScheduling.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 36 +++++++ scripts/rollup/forks.js | 2 + 8 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 packages/react-test-renderer/src/__tests__/ReactTestRendererAsync-test.js create mode 100644 packages/shared/forks/ReactFeatureFlags.test-renderer.js diff --git a/packages/react-native-renderer/src/ReactNativeFrameScheduling.js b/packages/react-native-renderer/src/ReactNativeFrameScheduling.js index d805a06cf3d7a..3f7e7e8818d58 100644 --- a/packages/react-native-renderer/src/ReactNativeFrameScheduling.js +++ b/packages/react-native-renderer/src/ReactNativeFrameScheduling.js @@ -23,6 +23,7 @@ let frameDeadline: number = 0; const frameDeadlineObject: Deadline = { timeRemaining: () => frameDeadline - now(), + didTimeout: false, }; function setTimeoutCallback() { diff --git a/packages/react-noop-renderer/src/ReactNoop.js b/packages/react-noop-renderer/src/ReactNoop.js index 852b6250626d5..15eef0fb762ed 100644 --- a/packages/react-noop-renderer/src/ReactNoop.js +++ b/packages/react-noop-renderer/src/ReactNoop.js @@ -308,6 +308,10 @@ function* flushUnitsOfWork(n: number): Generator, void, void> { didStop = true; return 0; }, + // 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) { diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index bb13271f0603d..09c1b21d9599a 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -37,6 +37,7 @@ if (__DEV__) { export type Deadline = { timeRemaining: () => number, + didTimeout: boolean, }; type OpaqueHandle = Fiber; diff --git a/packages/react-test-renderer/src/ReactTestRenderer.js b/packages/react-test-renderer/src/ReactTestRenderer.js index 625c8e94e3972..aa58411a7d88c 100644 --- a/packages/react-test-renderer/src/ReactTestRenderer.js +++ b/packages/react-test-renderer/src/ReactTestRenderer.js @@ -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'; @@ -31,6 +32,7 @@ import invariant from 'fbjs/lib/invariant'; type TestRendererOptions = { createNodeMock: (element: React$Element) => any, + unstable_isAsync: boolean, }; type ReactTestRendererJSON = {| @@ -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 | null = null; + const TestRenderer = ReactFiberReconciler({ getRootHostContext() { return emptyObject; @@ -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: { @@ -603,8 +613,14 @@ function propsMatch(props: Object, filter: Object): boolean { const ReactTestRendererFiber = { create(element: React$Element, 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: [], @@ -613,7 +629,7 @@ const ReactTestRendererFiber = { }; let root: FiberRoot | null = TestRenderer.createContainer( container, - false, + isAsync, false, ); invariant(root != null, 'something went wrong'); @@ -654,6 +670,66 @@ const ReactTestRendererFiber = { container = null; root = null; }, + unstable_flushAll(): Array { + 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): Array { + 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; diff --git a/packages/react-test-renderer/src/__tests__/ReactTestRendererAsync-test.js b/packages/react-test-renderer/src/__tests__/ReactTestRendererAsync-test.js new file mode 100644 index 0000000000000..b9574012bdfb6 --- /dev/null +++ b/packages/react-test-renderer/src/__tests__/ReactTestRendererAsync-test.js @@ -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(Hi, { + 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(Bye); + // 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 ( + + {'A:' + props.step} + {'B:' + props.step} + {'C:' + props.step} + + ); + } + const renderer = ReactTestRenderer.create(, { + 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(); + 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 ( + + {'A:' + props.step} + {'B:' + props.step} + {'C:' + props.step} + + ); + } + const renderer = ReactTestRenderer.create(, { + 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']); + }); +}); diff --git a/packages/shared/ReactDOMFrameScheduling.js b/packages/shared/ReactDOMFrameScheduling.js index 8e50bee94420c..514ca07c22e36 100644 --- a/packages/shared/ReactDOMFrameScheduling.js +++ b/packages/shared/ReactDOMFrameScheduling.js @@ -63,6 +63,7 @@ if (!ExecutionEnvironment.canUseDOM) { timeRemaining() { return Infinity; }, + didTimeout: false, }); }); }; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js new file mode 100644 index 0000000000000..e5d4ef7d6b51c --- /dev/null +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -0,0 +1,36 @@ +/** + * 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. + * + * @flow + */ + +import invariant from 'fbjs/lib/invariant'; + +import typeof * as FeatureFlagsType from 'shared/ReactFeatureFlags'; +import typeof * as PersistentFeatureFlagsType from './ReactFeatureFlags.persistent'; + +export const debugRenderPhaseSideEffects = false; +export const debugRenderPhaseSideEffectsForStrictMode = false; +export const enableCreateRoot = false; +export const enableUserTimingAPI = __DEV__; +export const enableGetDerivedStateFromCatch = false; +export const warnAboutDeprecatedLifecycles = false; +export const replayFailedUnitOfWorkWithInvokeGuardedCallback = false; +export const enableMutatingReconciler = true; +export const enableNoopReconciler = false; +export const enablePersistentReconciler = false; +export const alwaysUseRequestIdleCallbackPolyfill = false; + +// Only used in www builds. +export function addUserTimingListener() { + invariant(false, 'Not implemented.'); +} + +// Flow magic to verify the exports of this file match the original version. +// eslint-disable-next-line no-unused-vars +type Check<_X, Y: _X, X: Y = _X> = null; +// eslint-disable-next-line no-unused-expressions +(null: Check); diff --git a/scripts/rollup/forks.js b/scripts/rollup/forks.js index 0833ae7013cc6..f6d2a0623cc27 100644 --- a/scripts/rollup/forks.js +++ b/scripts/rollup/forks.js @@ -38,6 +38,8 @@ const forks = Object.freeze({ return 'shared/forks/ReactFeatureFlags.native-fabric.js'; case 'react-reconciler/persistent': return 'shared/forks/ReactFeatureFlags.persistent.js'; + case 'react-test-renderer': + return 'shared/forks/ReactFeatureFlags.test-renderer.js'; default: switch (bundleType) { case FB_DEV: