diff --git a/packages/react-test-renderer/src/ReactShallowRenderer.js b/packages/react-test-renderer/src/ReactShallowRenderer.js index 9bf373d033caf..890596721b8d2 100644 --- a/packages/react-test-renderer/src/ReactShallowRenderer.js +++ b/packages/react-test-renderer/src/ReactShallowRenderer.js @@ -4,6 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * + * @flow */ import React from 'react'; @@ -13,18 +14,93 @@ import getComponentName from 'shared/getComponentName'; import shallowEqual from 'shared/shallowEqual'; import invariant from 'shared/invariant'; import checkPropTypes from 'prop-types/checkPropTypes'; +import ReactSharedInternals from 'shared/ReactSharedInternals'; +import {enableHooks} from 'shared/ReactFeatureFlags'; +import warning from 'shared/warning'; +import is from 'shared/objectIs'; + +import typeof {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactFiberDispatcher'; +import type {ReactContext} from 'shared/ReactTypes'; +import type {ReactElement} from 'shared/ReactElementType'; + +type BasicStateAction = (S => S) | S; +type Dispatch = A => void; + +type Update = { + action: A, + next: Update | null, +}; + +type UpdateQueue = { + last: Update | null, + dispatch: any, +}; + +type Hook = { + memoizedState: any, + queue: UpdateQueue | null, + next: Hook | null, +}; + +const {ReactCurrentDispatcher} = ReactSharedInternals; + +const RE_RENDER_LIMIT = 25; const emptyObject = {}; if (__DEV__) { Object.freeze(emptyObject); } +// In DEV, this is the name of the currently executing primitive hook +let currentHookNameInDev: ?string; + +function areHookInputsEqual( + nextDeps: Array, + prevDeps: Array | null, +) { + if (prevDeps === null) { + warning( + false, + '%s received a final argument during this render, but not during ' + + 'the previous render. Even though the final argument is optional, ' + + 'its type cannot change between renders.', + currentHookNameInDev, + ); + return false; + } + + // Don't bother comparing lengths in prod because these arrays should be + // passed inline. + if (nextDeps.length !== prevDeps.length) { + warning( + false, + 'The final argument passed to %s changed size between renders. The ' + + 'order and size of this array must remain constant.\n\n' + + 'Previous: %s\n' + + 'Incoming: %s', + currentHookNameInDev, + `[${nextDeps.join(', ')}]`, + `[${prevDeps.join(', ')}]`, + ); + } + for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) { + if (is(nextDeps[i], prevDeps[i])) { + continue; + } + return false; + } + return true; +} + class Updater { constructor(renderer) { this._renderer = renderer; this._callbacks = []; } + _renderer: ReactShallowRenderer; + _callbacks: Array; + _enqueueCallback(callback, publicInstance) { if (typeof callback === 'function' && publicInstance) { this._callbacks.push({ @@ -85,6 +161,18 @@ class Updater { } } +function createHook(): Hook { + return { + memoizedState: null, + queue: null, + next: null, + }; +} + +function basicStateReducer(state: S, action: BasicStateAction): S { + return typeof action === 'function' ? action(state) : action; +} + class ReactShallowRenderer { static createRenderer = function() { return new ReactShallowRenderer(); @@ -99,6 +187,289 @@ class ReactShallowRenderer { this._rendering = false; this._forcedUpdate = false; this._updater = new Updater(this); + if (enableHooks) { + this._dispatcher = this._createDispatcher(); + this._workInProgressHook = null; + this._firstWorkInProgressHook = null; + this._isReRender = false; + this._didScheduleRenderPhaseUpdate = false; + this._renderPhaseUpdates = null; + this._currentlyRenderingComponent = null; + this._numberOfReRenders = 0; + this._previousComponentIdentity = null; + } + } + + _context: null | Object; + _newState: null | Object; + _instance: any; + _element: null | ReactElement; + _rendered: null | mixed; + _updater: Updater; + _rendering: boolean; + _forcedUpdate: boolean; + _dispatcher: DispatcherType; + _workInProgressHook: null | Hook; + _firstWorkInProgressHook: null | Hook; + _currentlyRenderingComponent: null | Object; + _previousComponentIdentity: null | Object; + _renderPhaseUpdates: Map, Update> | null; + _isReRender: boolean; + _didScheduleRenderPhaseUpdate: boolean; + _numberOfReRenders: number; + + _validateCurrentlyRenderingComponent() { + invariant( + this._currentlyRenderingComponent !== null, + 'Hooks can only be called inside the body of a function component.', + ); + } + + _createDispatcher(): DispatcherType { + const useReducer = ( + reducer: (S, A) => S, + initialState: S, + initialAction: A | void | null, + ): [S, Dispatch] => { + this._validateCurrentlyRenderingComponent(); + this._createWorkInProgressHook(); + const workInProgressHook: Hook = (this._workInProgressHook: any); + if (this._isReRender) { + // This is a re-render. Apply the new render phase updates to the previous + // current hook. + const queue: UpdateQueue = (workInProgressHook.queue: any); + const dispatch: Dispatch = (queue.dispatch: any); + if (this._renderPhaseUpdates !== null) { + // Render phase updates are stored in a map of queue -> linked list + const firstRenderPhaseUpdate = this._renderPhaseUpdates.get(queue); + if (firstRenderPhaseUpdate !== undefined) { + (this._renderPhaseUpdates: any).delete(queue); + let newState = workInProgressHook.memoizedState; + let update = firstRenderPhaseUpdate; + do { + // Process this render phase update. We don't have to check the + // priority because it will always be the same as the current + // render's. + const action = update.action; + newState = reducer(newState, action); + update = update.next; + } while (update !== null); + + workInProgressHook.memoizedState = newState; + + return [newState, dispatch]; + } + } + return [workInProgressHook.memoizedState, dispatch]; + } else { + if (reducer === basicStateReducer) { + // Special case for `useState`. + if (typeof initialState === 'function') { + initialState = initialState(); + } + } else if (initialAction !== undefined && initialAction !== null) { + initialState = reducer(initialState, initialAction); + } + workInProgressHook.memoizedState = initialState; + const queue: UpdateQueue = (workInProgressHook.queue = { + last: null, + dispatch: null, + }); + const dispatch: Dispatch< + A, + > = (queue.dispatch = (this._dispatchAction.bind( + this, + (this._currentlyRenderingComponent: any), + queue, + ): any)); + return [workInProgressHook.memoizedState, dispatch]; + } + }; + + const useState = ( + initialState: (() => S) | S, + ): [S, Dispatch>] => { + return useReducer( + basicStateReducer, + // useReducer has a special case to support lazy useState initializers + (initialState: any), + ); + }; + + const useMemo = ( + nextCreate: () => T, + deps: Array | void | null, + ): T => { + this._validateCurrentlyRenderingComponent(); + this._createWorkInProgressHook(); + + const nextDeps = deps !== undefined && deps !== null ? deps : null; + + if ( + this._workInProgressHook !== null && + this._workInProgressHook.memoizedState !== null + ) { + const prevState = this._workInProgressHook.memoizedState; + const prevDeps = prevState[1]; + if (nextDeps !== null) { + if (areHookInputsEqual(nextDeps, prevDeps)) { + return prevState[0]; + } + } + } + + const nextValue = nextCreate(); + (this._workInProgressHook: any).memoizedState = [nextValue, nextDeps]; + return nextValue; + }; + + const useRef = (initialValue: T): {current: T} => { + this._validateCurrentlyRenderingComponent(); + this._createWorkInProgressHook(); + const previousRef = (this._workInProgressHook: any).memoizedState; + if (previousRef === null) { + const ref = {current: initialValue}; + if (__DEV__) { + Object.seal(ref); + } + (this._workInProgressHook: any).memoizedState = ref; + return ref; + } else { + return previousRef; + } + }; + + const readContext = ( + context: ReactContext, + observedBits: void | number | boolean, + ): T => { + return context._currentValue; + }; + + const noOp = () => { + this._validateCurrentlyRenderingComponent(); + }; + + const identity = (fn: Function): Function => { + return fn; + }; + + return { + readContext, + useCallback: (identity: any), + useContext: (context: ReactContext): T => { + this._validateCurrentlyRenderingComponent(); + return readContext(context); + }, + useDebugValue: noOp, + useEffect: noOp, + useImperativeHandle: noOp, + useLayoutEffect: noOp, + useMemo, + useReducer, + useRef, + useState, + }; + } + + _dispatchAction( + componentIdentity: Object, + queue: UpdateQueue, + action: A, + ) { + invariant( + this._numberOfReRenders < RE_RENDER_LIMIT, + 'Too many re-renders. React limits the number of renders to prevent ' + + 'an infinite loop.', + ); + + if (componentIdentity === this._currentlyRenderingComponent) { + // This is a render phase update. Stash it in a lazily-created map of + // queue -> linked list of updates. After this render pass, we'll restart + // and apply the stashed updates on top of the work-in-progress hook. + this._didScheduleRenderPhaseUpdate = true; + const update: Update = { + action, + next: null, + }; + let renderPhaseUpdates = this._renderPhaseUpdates; + if (renderPhaseUpdates === null) { + this._renderPhaseUpdates = renderPhaseUpdates = new Map(); + } + const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue); + if (firstRenderPhaseUpdate === undefined) { + renderPhaseUpdates.set(queue, update); + } else { + // Append the update to the end of the list. + let lastRenderPhaseUpdate = firstRenderPhaseUpdate; + while (lastRenderPhaseUpdate.next !== null) { + lastRenderPhaseUpdate = lastRenderPhaseUpdate.next; + } + lastRenderPhaseUpdate.next = update; + } + } else { + // This means an update has happened after the function component has + // returned. On the server this is a no-op. In React Fiber, the update + // would be scheduled for a future render. + } + } + + _createWorkInProgressHook(): Hook { + if (this._workInProgressHook === null) { + // This is the first hook in the list + if (this._firstWorkInProgressHook === null) { + this._isReRender = false; + this._firstWorkInProgressHook = this._workInProgressHook = createHook(); + } else { + // There's already a work-in-progress. Reuse it. + this._isReRender = true; + this._workInProgressHook = this._firstWorkInProgressHook; + } + } else { + if (this._workInProgressHook.next === null) { + this._isReRender = false; + // Append to the end of the list + this._workInProgressHook = (this + ._workInProgressHook: any).next = createHook(); + } else { + // There's already a work-in-progress. Reuse it. + this._isReRender = true; + this._workInProgressHook = this._workInProgressHook.next; + } + } + return this._workInProgressHook; + } + + _prepareToUseHooks(componentIdentity: Object): void { + if ( + this._previousComponentIdentity !== null && + this._previousComponentIdentity !== componentIdentity + ) { + this._firstWorkInProgressHook = null; + } + this._currentlyRenderingComponent = componentIdentity; + this._previousComponentIdentity = componentIdentity; + } + + _finishHooks(element: ReactElement, context: null | Object) { + if (this._didScheduleRenderPhaseUpdate) { + // Updates were scheduled during the render phase. They are stored in + // the `renderPhaseUpdates` map. Call the component again, reusing the + // work-in-progress hooks and applying the additional updates on top. Keep + // restarting until no more updates are scheduled. + this._didScheduleRenderPhaseUpdate = false; + this._numberOfReRenders += 1; + + // Start over from the beginning of the list + this._workInProgressHook = null; + this._rendering = false; + this.render(element, context); + } else { + this._currentlyRenderingComponent = null; + this._workInProgressHook = null; + this._renderPhaseUpdates = null; + this._numberOfReRenders = 0; + } } getMountedInstance() { @@ -109,7 +480,7 @@ class ReactShallowRenderer { return this._rendered; } - render(element, context = emptyObject) { + render(element: ReactElement | null, context: null | Object = emptyObject) { invariant( React.isValidElement(element), 'ReactShallowRenderer render(): Invalid component element.%s', @@ -118,6 +489,7 @@ class ReactShallowRenderer { 'it by passing it to React.createElement.' : '', ); + element = ((element: any): ReactElement); // Show a special message for host elements since it's a common case. invariant( typeof element.type !== 'string', @@ -175,11 +547,27 @@ class ReactShallowRenderer { this._mountClassComponent(element, this._context); } else { - this._rendered = element.type.call( - undefined, - element.props, - this._context, - ); + if (enableHooks) { + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = this._dispatcher; + this._prepareToUseHooks(element.type); + try { + this._rendered = element.type.call( + undefined, + element.props, + this._context, + ); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + this._finishHooks(element, context); + } else { + this._rendered = element.type.call( + undefined, + element.props, + this._context, + ); + } } } @@ -196,6 +584,8 @@ class ReactShallowRenderer { } } + this._firstWorkInProgressHook = null; + this._previousComponentIdentity = null; this._context = null; this._element = null; this._newState = null; @@ -203,7 +593,7 @@ class ReactShallowRenderer { this._instance = null; } - _mountClassComponent(element, context) { + _mountClassComponent(element: ReactElement, context: null | Object) { this._instance.context = context; this._instance.props = element.props; this._instance.state = this._instance.state || null; @@ -240,7 +630,7 @@ class ReactShallowRenderer { // because DOM refs are not available. } - _updateClassComponent(element, context) { + _updateClassComponent(element: ReactElement, context: null | Object) { const {props, type} = element; const oldState = this._instance.state || emptyObject; @@ -310,7 +700,10 @@ class ReactShallowRenderer { // because DOM refs are not available. } - _updateStateFromStaticLifecycle(props) { + _updateStateFromStaticLifecycle(props: Object) { + if (this._element === null) { + return; + } const {type} = this._element; if (typeof type.getDerivedStateFromProps === 'function') { @@ -373,7 +766,7 @@ function shouldConstruct(Component) { } function getMaskedContext(contextTypes, unmaskedContext) { - if (!contextTypes) { + if (!contextTypes || !unmaskedContext) { return emptyObject; } const context = {}; diff --git a/packages/react-test-renderer/src/__tests__/ReactShallowRendererHooks-test.internal.js b/packages/react-test-renderer/src/__tests__/ReactShallowRendererHooks-test.internal.js new file mode 100644 index 0000000000000..2b1f3f56b8238 --- /dev/null +++ b/packages/react-test-renderer/src/__tests__/ReactShallowRendererHooks-test.internal.js @@ -0,0 +1,320 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * 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'; + +let createRenderer; +let React; + +describe('ReactShallowRenderer with hooks', () => { + beforeEach(() => { + jest.resetModules(); + let ReactFeatureFlags = require('shared/ReactFeatureFlags'); + // TODO: Switch this test to non-internal once the flag is on by default. + ReactFeatureFlags.enableHooks = true; + createRenderer = require('react-test-renderer/shallow').createRenderer; + React = require('react'); + }); + + it('should work with useState', () => { + function SomeComponent({defaultName}) { + const [name] = React.useState(defaultName); + + return ( +
+

+ Your name is: {name} +

+
+ ); + } + + const shallowRenderer = createRenderer(); + let result = shallowRenderer.render( + , + ); + + expect(result).toEqual( +
+

+ Your name is: Dominic +

+
, + ); + + result = shallowRenderer.render( + , + ); + + expect(result).toEqual( +
+

+ Your name is: Dominic +

+
, + ); + }); + + it('should work with updating a value from useState', () => { + function SomeComponent({defaultName}) { + const [name, updateName] = React.useState(defaultName); + + if (name !== 'Dan') { + updateName('Dan'); + } + + return ( +
+

+ Your name is: {name} +

+
+ ); + } + + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render( + , + ); + + expect(result).toEqual( +
+

+ Your name is: Dan +

+
, + ); + }); + + it('should work with useReducer', () => { + const initialState = {count: 0}; + + function reducer(state, action) { + switch (action.type) { + case 'reset': + return initialState; + case 'increment': + return {count: state.count + 1}; + case 'decrement': + return {count: state.count - 1}; + default: + return state; + } + } + + function SomeComponent({initialCount}) { + const [state] = React.useReducer(reducer, {count: initialCount}); + + return ( +
+

+ The counter is at: {state.count.toString()} +

+
+ ); + } + + const shallowRenderer = createRenderer(); + let result = shallowRenderer.render(); + + expect(result).toEqual( +
+

+ The counter is at: 0 +

+
, + ); + + result = shallowRenderer.render(); + + expect(result).toEqual( +
+

+ The counter is at: 0 +

+
, + ); + }); + + it('should work with a dispatched state change for a useReducer', () => { + const initialState = {count: 0}; + + function reducer(state, action) { + switch (action.type) { + case 'reset': + return initialState; + case 'increment': + return {count: state.count + 1}; + case 'decrement': + return {count: state.count - 1}; + default: + return state; + } + } + + function SomeComponent({initialCount}) { + const [state, dispatch] = React.useReducer(reducer, { + count: initialCount, + }); + + if (state.count === 0) { + dispatch({type: 'increment'}); + } + + return ( +
+

+ The counter is at: {state.count.toString()} +

+
+ ); + } + + const shallowRenderer = createRenderer(); + let result = shallowRenderer.render(); + + expect(result).toEqual( +
+

+ The counter is at: 1 +

+
, + ); + }); + + it('should not trigger effects', () => { + let effectsCalled = []; + + function SomeComponent({defaultName}) { + React.useEffect(() => { + effectsCalled.push('useEffect'); + }); + + React.useLayoutEffect(() => { + effectsCalled.push('useEffect'); + }); + + return
Hello world
; + } + + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + + expect(effectsCalled).toEqual([]); + }); + + it('should work with useRef', () => { + function SomeComponent() { + const randomNumberRef = React.useRef({number: Math.random()}); + + return ( +
+

The random number is: {randomNumberRef.current.number}

+
+ ); + } + + const shallowRenderer = createRenderer(); + let firstResult = shallowRenderer.render(); + let secondResult = shallowRenderer.render(); + + expect(firstResult).toEqual(secondResult); + }); + + it('should work with useMemo', () => { + function SomeComponent() { + const randomNumber = React.useMemo(() => { + return {number: Math.random()}; + }, []); + + return ( +
+

The random number is: {randomNumber.number}

+
+ ); + } + + const shallowRenderer = createRenderer(); + let firstResult = shallowRenderer.render(); + let secondResult = shallowRenderer.render(); + + expect(firstResult).toEqual(secondResult); + }); + + it('should work with useContext', () => { + const SomeContext = React.createContext('default'); + + function SomeComponent() { + const value = React.useContext(SomeContext); + + return ( +
+

{value}

+
+ ); + } + + const shallowRenderer = createRenderer(); + let result = shallowRenderer.render(); + + expect(result).toEqual( +
+

default

+
, + ); + }); + + it('should not leak state when component type changes', () => { + function SomeComponent({defaultName}) { + const [name] = React.useState(defaultName); + + return ( +
+

+ Your name is: {name} +

+
+ ); + } + + function SomeOtherComponent({defaultName}) { + const [name] = React.useState(defaultName); + + return ( +
+

+ Your name is: {name} +

+
+ ); + } + + const shallowRenderer = createRenderer(); + let result = shallowRenderer.render( + , + ); + expect(result).toEqual( +
+

+ Your name is: Dominic +

+
, + ); + + result = shallowRenderer.render(); + expect(result).toEqual( +
+

+ Your name is: Dan +

+
, + ); + }); +});