diff --git a/packages/react-test-renderer/src/ReactShallowRenderer.js b/packages/react-test-renderer/src/ReactShallowRenderer.js index a2775eb2c446c..95081009a0ea5 100644 --- a/packages/react-test-renderer/src/ReactShallowRenderer.js +++ b/packages/react-test-renderer/src/ReactShallowRenderer.js @@ -8,7 +8,7 @@ */ import React from 'react'; -import {isForwardRef} from 'react-is'; +import {isForwardRef, isMemo, ForwardRef} from 'react-is'; import describeComponentFrame from 'shared/describeComponentFrame'; import getComponentName from 'shared/getComponentName'; import shallowEqual from 'shared/shallowEqual'; @@ -500,7 +500,8 @@ class ReactShallowRenderer { element.type, ); invariant( - isForwardRef(element) || typeof element.type === 'function', + isForwardRef(element) || + (typeof element.type === 'function' || isMemo(element.type)), 'ReactShallowRenderer render(): Shallow rendering works only with custom ' + 'components, but the provided element type was `%s`.', Array.isArray(element.type) @@ -514,22 +515,36 @@ class ReactShallowRenderer { return; } + const elementType = isMemo(element.type) ? element.type.type : element.type; + const previousElement = this._element; + this._rendering = true; this._element = element; - this._context = getMaskedContext(element.type.contextTypes, context); + this._context = getMaskedContext(elementType.contextTypes, context); + + // Inner memo component props aren't currently validated in createElement. + if (isMemo(element.type) && elementType.propTypes) { + currentlyValidatingElement = element; + checkPropTypes( + elementType.propTypes, + element.props, + 'prop', + getComponentName(elementType), + getStackAddendum, + ); + } if (this._instance) { - this._updateClassComponent(element, this._context); + this._updateClassComponent(elementType, element, this._context); } else { - if (shouldConstruct(element.type)) { - this._instance = new element.type( + if (shouldConstruct(elementType)) { + this._instance = new elementType( element.props, this._context, this._updater, ); - - if (typeof element.type.getDerivedStateFromProps === 'function') { - const partialState = element.type.getDerivedStateFromProps.call( + if (typeof elementType.getDerivedStateFromProps === 'function') { + const partialState = elementType.getDerivedStateFromProps.call( null, element.props, this._instance.state, @@ -543,39 +558,59 @@ class ReactShallowRenderer { } } - if (element.type.hasOwnProperty('contextTypes')) { + if (elementType.contextTypes) { currentlyValidatingElement = element; - checkPropTypes( - element.type.contextTypes, + elementType.contextTypes, this._context, 'context', - getName(element.type, this._instance), + getName(elementType, this._instance), getStackAddendum, ); currentlyValidatingElement = null; } - this._mountClassComponent(element, this._context); + this._mountClassComponent(elementType, element, this._context); } else { - const prevDispatcher = ReactCurrentDispatcher.current; - ReactCurrentDispatcher.current = this._dispatcher; - this._prepareToUseHooks(element.type); - try { - if (isForwardRef(element)) { - this._rendered = element.type.render(element.props, element.ref); - } else { - this._rendered = element.type.call( - undefined, - element.props, - this._context, - ); + let shouldRender = true; + if ( + isMemo(element.type) && + elementType === this._previousComponentIdentity && + previousElement !== null + ) { + // This is a Memo component that is being re-rendered. + const compare = element.type.compare || shallowEqual; + if (compare(previousElement.props, element.props)) { + shouldRender = false; + } + } + if (shouldRender) { + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = this._dispatcher; + this._prepareToUseHooks(elementType); + try { + // elementType could still be a ForwardRef if it was + // nested inside Memo. + if (elementType.$$typeof === ForwardRef) { + invariant( + typeof elementType.render === 'function', + 'forwardRef requires a render function but was given %s.', + typeof elementType.render, + ); + this._rendered = elementType.render.call( + undefined, + element.props, + element.ref, + ); + } else { + this._rendered = elementType(element.props, this._context); + } + } finally { + ReactCurrentDispatcher.current = prevDispatcher; } - } finally { - ReactCurrentDispatcher.current = prevDispatcher; + this._finishHooks(element, context); } - this._finishHooks(element, context); } } @@ -601,7 +636,11 @@ class ReactShallowRenderer { this._instance = null; } - _mountClassComponent(element: ReactElement, context: null | Object) { + _mountClassComponent( + elementType: Function, + element: ReactElement, + context: null | Object, + ) { this._instance.context = context; this._instance.props = element.props; this._instance.state = this._instance.state || null; @@ -616,7 +655,7 @@ class ReactShallowRenderer { // In order to support react-lifecycles-compat polyfilled components, // Unsafe lifecycles should not be invoked for components using the new APIs. if ( - typeof element.type.getDerivedStateFromProps !== 'function' && + typeof elementType.getDerivedStateFromProps !== 'function' && typeof this._instance.getSnapshotBeforeUpdate !== 'function' ) { if (typeof this._instance.componentWillMount === 'function') { @@ -638,8 +677,12 @@ class ReactShallowRenderer { // because DOM refs are not available. } - _updateClassComponent(element: ReactElement, context: null | Object) { - const {props, type} = element; + _updateClassComponent( + elementType: Function, + element: ReactElement, + context: null | Object, + ) { + const {props} = element; const oldState = this._instance.state || emptyObject; const oldProps = this._instance.props; @@ -648,7 +691,7 @@ class ReactShallowRenderer { // In order to support react-lifecycles-compat polyfilled components, // Unsafe lifecycles should not be invoked for components using the new APIs. if ( - typeof element.type.getDerivedStateFromProps !== 'function' && + typeof elementType.getDerivedStateFromProps !== 'function' && typeof this._instance.getSnapshotBeforeUpdate !== 'function' ) { if (typeof this._instance.componentWillReceiveProps === 'function') { @@ -664,8 +707,8 @@ class ReactShallowRenderer { // Read state after cWRP in case it calls setState let state = this._newState || oldState; - if (typeof type.getDerivedStateFromProps === 'function') { - const partialState = type.getDerivedStateFromProps.call( + if (typeof elementType.getDerivedStateFromProps === 'function') { + const partialState = elementType.getDerivedStateFromProps.call( null, props, state, @@ -685,7 +728,10 @@ class ReactShallowRenderer { state, context, ); - } else if (type.prototype && type.prototype.isPureReactComponent) { + } else if ( + elementType.prototype && + elementType.prototype.isPureReactComponent + ) { shouldUpdate = !shallowEqual(oldProps, props) || !shallowEqual(oldState, state); } @@ -694,7 +740,7 @@ class ReactShallowRenderer { // In order to support react-lifecycles-compat polyfilled components, // Unsafe lifecycles should not be invoked for components using the new APIs. if ( - typeof element.type.getDerivedStateFromProps !== 'function' && + typeof elementType.getDerivedStateFromProps !== 'function' && typeof this._instance.getSnapshotBeforeUpdate !== 'function' ) { if (typeof this._instance.componentWillUpdate === 'function') { @@ -729,7 +775,8 @@ function getDisplayName(element) { } else if (typeof element.type === 'string') { return element.type; } else { - return element.type.displayName || element.type.name || 'Unknown'; + const elementType = isMemo(element.type) ? element.type.type : element.type; + return elementType.displayName || elementType.name || 'Unknown'; } } diff --git a/packages/react-test-renderer/src/__tests__/ReactShallowRenderer-test.js b/packages/react-test-renderer/src/__tests__/ReactShallowRenderer-test.js index d83dada160f90..df7a08d9d5c0d 100644 --- a/packages/react-test-renderer/src/__tests__/ReactShallowRenderer-test.js +++ b/packages/react-test-renderer/src/__tests__/ReactShallowRenderer-test.js @@ -1454,4 +1454,115 @@ describe('ReactShallowRenderer', () => { shallowRenderer.render(); expect(logs).toEqual([undefined]); }); + + it('should handle memo', () => { + function Foo() { + return
foo
; + } + const MemoFoo = React.memo(Foo); + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + }); + + it('should enable React.memo to prevent a re-render', () => { + const logs = []; + const Foo = React.memo(({count}) => { + logs.push(`Foo: ${count}`); + return
{count}
; + }); + const Bar = React.memo(({count}) => { + logs.push(`Bar: ${count}`); + return
{count}
; + }); + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + expect(logs).toEqual(['Foo: 1']); + logs.length = 0; + // Rendering the same element with the same props should be prevented + shallowRenderer.render(); + expect(logs).toEqual([]); + // A different element with the same props should cause a re-render + shallowRenderer.render(); + expect(logs).toEqual(['Bar: 1']); + }); + + it('should respect a custom comparison function with React.memo', () => { + let renderCount = 0; + function areEqual(props, nextProps) { + return props.foo === nextProps.foo; + } + const Foo = React.memo(({foo, bar}) => { + renderCount++; + return ( +
+ {foo} {bar} +
+ ); + }, areEqual); + + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + expect(renderCount).toBe(1); + // Change a prop that the comparison funciton ignores + shallowRenderer.render(); + expect(renderCount).toBe(1); + shallowRenderer.render(); + expect(renderCount).toBe(2); + }); + + it('should not call the comparison function with React.memo on the initial render', () => { + const areEqual = jest.fn(() => false); + const SomeComponent = React.memo(({foo}) => { + return
{foo}
; + }, areEqual); + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + expect(areEqual).not.toHaveBeenCalled(); + expect(shallowRenderer.getRenderOutput()).toEqual(
{1}
); + }); + + it('should handle memo(forwardRef())', () => { + const testRef = React.createRef(); + const SomeComponent = React.forwardRef((props, ref) => { + expect(ref).toEqual(testRef); + return ( +
+ + +
+ ); + }); + + const SomeMemoComponent = React.memo(SomeComponent); + + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render(); + + expect(result.type).toBe('div'); + expect(result.props.children).toEqual([ + , + , + ]); + }); + + it('should warn for forwardRef(memo())', () => { + const testRef = React.createRef(); + const SomeMemoComponent = React.memo(({foo}) => { + return
{foo}
; + }); + const shallowRenderer = createRenderer(); + expect(() => { + expect(() => { + const SomeComponent = React.forwardRef(SomeMemoComponent); + shallowRenderer.render(); + }).toWarnDev( + 'Warning: forwardRef requires a render function but received ' + + 'a `memo` component. Instead of forwardRef(memo(...)), use ' + + 'memo(forwardRef(...))', + {withoutStack: true}, + ); + }).toThrowError( + 'forwardRef requires a render function but was given object.', + ); + }); }); diff --git a/packages/react-test-renderer/src/__tests__/ReactShallowRendererMemo-test.js b/packages/react-test-renderer/src/__tests__/ReactShallowRendererMemo-test.js new file mode 100644 index 0000000000000..839524661459f --- /dev/null +++ b/packages/react-test-renderer/src/__tests__/ReactShallowRendererMemo-test.js @@ -0,0 +1,1520 @@ +/** + * 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 PropTypes; +let React; + +describe('ReactShallowRendererMemo', () => { + beforeEach(() => { + jest.resetModules(); + + createRenderer = require('react-test-renderer/shallow').createRenderer; + PropTypes = require('prop-types'); + React = require('react'); + }); + + it('should call all of the legacy lifecycle hooks', () => { + const logs = []; + const logger = message => () => logs.push(message) || true; + + const SomeComponent = React.memo( + class SomeComponent extends React.Component { + UNSAFE_componentWillMount = logger('componentWillMount'); + componentDidMount = logger('componentDidMount'); + UNSAFE_componentWillReceiveProps = logger('componentWillReceiveProps'); + shouldComponentUpdate = logger('shouldComponentUpdate'); + UNSAFE_componentWillUpdate = logger('componentWillUpdate'); + componentDidUpdate = logger('componentDidUpdate'); + componentWillUnmount = logger('componentWillUnmount'); + render() { + return
; + } + }, + ); + + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + + // Calling cDU might lead to problems with host component references. + // Since our components aren't really mounted, refs won't be available. + expect(logs).toEqual(['componentWillMount']); + + logs.splice(0); + + const instance = shallowRenderer.getMountedInstance(); + instance.setState({}); + + expect(logs).toEqual(['shouldComponentUpdate', 'componentWillUpdate']); + + logs.splice(0); + + shallowRenderer.render(); + + // The previous shallow renderer did not trigger cDU for props changes. + expect(logs).toEqual([ + 'componentWillReceiveProps', + 'shouldComponentUpdate', + 'componentWillUpdate', + ]); + }); + + it('should call all of the new lifecycle hooks', () => { + const logs = []; + const logger = message => () => logs.push(message) || true; + + const SomeComponent = React.memo( + class SomeComponent extends React.Component { + state = {}; + static getDerivedStateFromProps = logger('getDerivedStateFromProps'); + componentDidMount = logger('componentDidMount'); + shouldComponentUpdate = logger('shouldComponentUpdate'); + componentDidUpdate = logger('componentDidUpdate'); + componentWillUnmount = logger('componentWillUnmount'); + render() { + return
; + } + }, + ); + + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + + // Calling cDU might lead to problems with host component references. + // Since our components aren't really mounted, refs won't be available. + expect(logs).toEqual(['getDerivedStateFromProps']); + + logs.splice(0); + + const instance = shallowRenderer.getMountedInstance(); + instance.setState({}); + + expect(logs).toEqual(['getDerivedStateFromProps', 'shouldComponentUpdate']); + + logs.splice(0); + + shallowRenderer.render(); + + // The previous shallow renderer did not trigger cDU for props changes. + expect(logs).toEqual(['getDerivedStateFromProps', 'shouldComponentUpdate']); + }); + + it('should not invoke deprecated lifecycles (cWM/cWRP/cWU) if new static gDSFP is present', () => { + const Component = React.memo( + class Component extends React.Component { + state = {}; + static getDerivedStateFromProps() { + return null; + } + componentWillMount() { + throw Error('unexpected'); + } + componentWillReceiveProps() { + throw Error('unexpected'); + } + componentWillUpdate() { + throw Error('unexpected'); + } + render() { + return null; + } + }, + ); + + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + }); + + it('should not invoke deprecated lifecycles (cWM/cWRP/cWU) if new getSnapshotBeforeUpdate is present', () => { + const Component = React.memo( + class Component extends React.Component { + getSnapshotBeforeUpdate() { + return null; + } + componentWillMount() { + throw Error('unexpected'); + } + componentWillReceiveProps() { + throw Error('unexpected'); + } + componentWillUpdate() { + throw Error('unexpected'); + } + render() { + return null; + } + }, + ); + + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + shallowRenderer.render(); + }); + + it('should not call getSnapshotBeforeUpdate or componentDidUpdate when updating since refs wont exist', () => { + const Component = React.memo( + class Component extends React.Component { + getSnapshotBeforeUpdate() { + throw Error('unexpected'); + } + componentDidUpdate() { + throw Error('unexpected'); + } + render() { + return null; + } + }, + ); + + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + shallowRenderer.render(); + }); + + it('should only render 1 level deep', () => { + const Parent = React.memo(function Parent() { + return ( +
+ +
+ ); + }); + + function Child() { + throw Error('This component should not render'); + } + + const shallowRenderer = createRenderer(); + shallowRenderer.render(React.createElement(Parent)); + }); + + it('should have shallow rendering', () => { + const SomeComponent = React.memo( + class SomeComponent extends React.Component { + render() { + return ( +
+ + +
+ ); + } + }, + ); + + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render(); + + expect(result.type).toBe('div'); + expect(result.props.children).toEqual([ + , + , + ]); + }); + + it('should handle Profiler', () => { + const SomeComponent = React.memo( + class SomeComponent extends React.Component { + render() { + return ( + +
+ + +
+
+ ); + } + }, + ); + + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render(); + + expect(result.type).toBe(React.unstable_Profiler); + expect(result.props.children).toEqual( +
+ + +
, + ); + }); + + it('should enable shouldComponentUpdate to prevent a re-render', () => { + let renderCounter = 0; + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + state = {update: false}; + shouldComponentUpdate(nextProps, nextState) { + return this.state.update !== nextState.update; + } + render() { + renderCounter++; + return
{`${renderCounter}`}
; + } + }, + ); + + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + expect(shallowRenderer.getRenderOutput()).toEqual(
1
); + + const instance = shallowRenderer.getMountedInstance(); + instance.setState({update: false}); + expect(shallowRenderer.getRenderOutput()).toEqual(
1
); + + instance.setState({update: true}); + expect(shallowRenderer.getRenderOutput()).toEqual(
2
); + }); + + it('should enable PureComponent to prevent a re-render', () => { + let renderCounter = 0; + const SimpleComponent = React.memo( + class SimpleComponent extends React.PureComponent { + state = {update: false}; + render() { + renderCounter++; + return
{`${renderCounter}`}
; + } + }, + ); + + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + expect(shallowRenderer.getRenderOutput()).toEqual(
1
); + + const instance = shallowRenderer.getMountedInstance(); + instance.setState({update: false}); + expect(shallowRenderer.getRenderOutput()).toEqual(
1
); + + instance.setState({update: true}); + expect(shallowRenderer.getRenderOutput()).toEqual(
2
); + }); + + it('should not run shouldComponentUpdate during forced update', () => { + let scuCounter = 0; + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + state = {count: 1}; + shouldComponentUpdate() { + scuCounter++; + return false; + } + render() { + return
{`${this.state.count}`}
; + } + }, + ); + + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + expect(scuCounter).toEqual(0); + expect(shallowRenderer.getRenderOutput()).toEqual(
1
); + + // Force update the initial state. sCU should not fire. + const instance = shallowRenderer.getMountedInstance(); + instance.forceUpdate(); + expect(scuCounter).toEqual(0); + expect(shallowRenderer.getRenderOutput()).toEqual(
1
); + + // Setting state updates the instance, but doesn't re-render + // because sCU returned false. + instance.setState(state => ({count: state.count + 1})); + expect(scuCounter).toEqual(1); + expect(instance.state.count).toEqual(2); + expect(shallowRenderer.getRenderOutput()).toEqual(
1
); + + // A force update updates the render output, but doesn't call sCU. + instance.forceUpdate(); + expect(scuCounter).toEqual(1); + expect(instance.state.count).toEqual(2); + expect(shallowRenderer.getRenderOutput()).toEqual(
2
); + }); + + it('should rerender when calling forceUpdate', () => { + let renderCounter = 0; + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + render() { + renderCounter += 1; + return
; + } + }, + ); + + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + expect(renderCounter).toEqual(1); + + const instance = shallowRenderer.getMountedInstance(); + instance.forceUpdate(); + expect(renderCounter).toEqual(2); + }); + + it('should shallow render a function component', () => { + function SomeComponent(props, context) { + return ( +
+
{props.foo}
+
{context.bar}
+ + +
+ ); + } + const SomeMemoComponent = React.memo(SomeComponent); + + SomeComponent.contextTypes = { + bar: PropTypes.string, + }; + + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render(, { + bar: 'BAR', + }); + + expect(result.type).toBe('div'); + expect(result.props.children).toEqual([ +
FOO
, +
BAR
, + , + , + ]); + }); + + it('should shallow render a component returning strings directly from render', () => { + const Text = React.memo(({value}) => value); + + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render(); + expect(result).toEqual('foo'); + }); + + it('should shallow render a component returning numbers directly from render', () => { + const Text = React.memo(({value}) => value); + + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render(); + expect(result).toEqual(10); + }); + + it('should shallow render a fragment', () => { + class SomeComponent extends React.Component { + render() { + return
; + } + } + class Fragment extends React.Component { + render() { + return [
, , ]; + } + } + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render(); + expect(result).toEqual([ +
, + , + , + ]); + }); + + it('should shallow render a React.fragment', () => { + class SomeComponent extends React.Component { + render() { + return
; + } + } + class Fragment extends React.Component { + render() { + return ( + +
+ + + + ); + } + } + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render(); + expect(result).toEqual( + +
+ + + , + ); + }); + + it('should throw for invalid elements', () => { + class SomeComponent extends React.Component { + render() { + return
; + } + } + + const shallowRenderer = createRenderer(); + expect(() => shallowRenderer.render(SomeComponent)).toThrowError( + 'ReactShallowRenderer render(): Invalid component element. Instead of ' + + 'passing a component class, make sure to instantiate it by passing it ' + + 'to React.createElement.', + ); + expect(() => shallowRenderer.render(
)).toThrowError( + 'ReactShallowRenderer render(): Shallow rendering works only with ' + + 'custom components, not primitives (div). Instead of calling ' + + '`.render(el)` and inspecting the rendered output, look at `el.props` ' + + 'directly instead.', + ); + }); + + it('should have shallow unmounting', () => { + const componentWillUnmount = jest.fn(); + + class SomeComponent extends React.Component { + componentWillUnmount = componentWillUnmount; + render() { + return
; + } + } + + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + shallowRenderer.unmount(); + + expect(componentWillUnmount).toBeCalled(); + }); + + it('can shallow render to null', () => { + class SomeComponent extends React.Component { + render() { + return null; + } + } + + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render(); + + expect(result).toBe(null); + }); + + it('can shallow render with a ref', () => { + class SomeComponent extends React.Component { + render() { + return
; + } + } + + const shallowRenderer = createRenderer(); + // Shouldn't crash. + shallowRenderer.render(); + }); + + it('lets you update shallowly rendered components', () => { + class SomeComponent extends React.Component { + state = {clicked: false}; + + onClick = () => { + this.setState({clicked: true}); + }; + + render() { + const className = this.state.clicked ? 'was-clicked' : ''; + + if (this.props.aNew === 'prop') { + return ( + + Test link + + ); + } else { + return ( +
+ + +
+ ); + } + } + } + + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render(); + expect(result.type).toBe('div'); + expect(result.props.children).toEqual([ + , + , + ]); + + const updatedResult = shallowRenderer.render(); + expect(updatedResult.type).toBe('a'); + + const mockEvent = {}; + updatedResult.props.onClick(mockEvent); + + const updatedResultCausedByClick = shallowRenderer.getRenderOutput(); + expect(updatedResultCausedByClick.type).toBe('a'); + expect(updatedResultCausedByClick.props.className).toBe('was-clicked'); + }); + + it('can access the mounted component instance', () => { + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + someMethod = () => { + return this.props.n; + }; + + render() { + return
{this.props.n}
; + } + }, + ); + + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + expect(shallowRenderer.getMountedInstance().someMethod()).toEqual(5); + }); + + it('can shallowly render components with contextTypes', () => { + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + static contextTypes = { + name: PropTypes.string, + }; + + render() { + return
; + } + }, + ); + + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render(); + expect(result).toEqual(
); + }); + + it('passes expected params to legacy component lifecycle methods', () => { + const componentDidUpdateParams = []; + const componentWillReceivePropsParams = []; + const componentWillUpdateParams = []; + const setStateParams = []; + const shouldComponentUpdateParams = []; + + const initialProp = {prop: 'init prop'}; + const initialState = {state: 'init state'}; + const initialContext = {context: 'init context'}; + const updatedState = {state: 'updated state'}; + const updatedProp = {prop: 'updated prop'}; + const updatedContext = {context: 'updated context'}; + + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + constructor(props, context) { + super(props, context); + this.state = initialState; + } + static contextTypes = { + context: PropTypes.string, + }; + componentDidUpdate(...args) { + componentDidUpdateParams.push(...args); + } + UNSAFE_componentWillReceiveProps(...args) { + componentWillReceivePropsParams.push(...args); + this.setState((...innerArgs) => { + setStateParams.push(...innerArgs); + return updatedState; + }); + } + UNSAFE_componentWillUpdate(...args) { + componentWillUpdateParams.push(...args); + } + shouldComponentUpdate(...args) { + shouldComponentUpdateParams.push(...args); + return true; + } + render() { + return null; + } + }, + ); + + const shallowRenderer = createRenderer(); + shallowRenderer.render( + React.createElement(SimpleComponent, initialProp), + initialContext, + ); + expect(componentDidUpdateParams).toEqual([]); + expect(componentWillReceivePropsParams).toEqual([]); + expect(componentWillUpdateParams).toEqual([]); + expect(setStateParams).toEqual([]); + expect(shouldComponentUpdateParams).toEqual([]); + + // Lifecycle hooks should be invoked with the correct prev/next params on update. + shallowRenderer.render( + React.createElement(SimpleComponent, updatedProp), + updatedContext, + ); + expect(componentWillReceivePropsParams).toEqual([ + updatedProp, + updatedContext, + ]); + expect(setStateParams).toEqual([initialState, initialProp]); + expect(shouldComponentUpdateParams).toEqual([ + updatedProp, + updatedState, + updatedContext, + ]); + expect(componentWillUpdateParams).toEqual([ + updatedProp, + updatedState, + updatedContext, + ]); + expect(componentDidUpdateParams).toEqual([]); + }); + + it('passes expected params to new component lifecycle methods', () => { + const componentDidUpdateParams = []; + const getDerivedStateFromPropsParams = []; + const shouldComponentUpdateParams = []; + + const initialProp = {prop: 'init prop'}; + const initialState = {state: 'init state'}; + const initialContext = {context: 'init context'}; + const updatedProp = {prop: 'updated prop'}; + const updatedContext = {context: 'updated context'}; + + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + constructor(props, context) { + super(props, context); + this.state = initialState; + } + static contextTypes = { + context: PropTypes.string, + }; + componentDidUpdate(...args) { + componentDidUpdateParams.push(...args); + } + static getDerivedStateFromProps(...args) { + getDerivedStateFromPropsParams.push(args); + return null; + } + shouldComponentUpdate(...args) { + shouldComponentUpdateParams.push(...args); + return true; + } + render() { + return null; + } + }, + ); + + const shallowRenderer = createRenderer(); + + // The only lifecycle hook that should be invoked on initial render + // Is the static getDerivedStateFromProps() methods + shallowRenderer.render( + React.createElement(SimpleComponent, initialProp), + initialContext, + ); + expect(getDerivedStateFromPropsParams).toEqual([ + [initialProp, initialState], + ]); + expect(componentDidUpdateParams).toEqual([]); + expect(shouldComponentUpdateParams).toEqual([]); + + // Lifecycle hooks should be invoked with the correct prev/next params on update. + shallowRenderer.render( + React.createElement(SimpleComponent, updatedProp), + updatedContext, + ); + expect(getDerivedStateFromPropsParams).toEqual([ + [initialProp, initialState], + [updatedProp, initialState], + ]); + expect(shouldComponentUpdateParams).toEqual([ + updatedProp, + initialState, + updatedContext, + ]); + expect(componentDidUpdateParams).toEqual([]); + }); + + it('can shallowly render components with ref as function', () => { + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + state = {clicked: false}; + + handleUserClick = () => { + this.setState({clicked: true}); + }; + + render() { + return ( +
{}} + onClick={this.handleUserClick} + className={this.state.clicked ? 'clicked' : ''} + /> + ); + } + }, + ); + + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + let result = shallowRenderer.getRenderOutput(); + expect(result.type).toEqual('div'); + expect(result.props.className).toEqual(''); + result.props.onClick(); + + result = shallowRenderer.getRenderOutput(); + expect(result.type).toEqual('div'); + expect(result.props.className).toEqual('clicked'); + }); + + it('can initialize state via static getDerivedStateFromProps', () => { + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + state = { + count: 1, + }; + + static getDerivedStateFromProps(props, prevState) { + return { + count: prevState.count + props.incrementBy, + other: 'foobar', + }; + } + + render() { + return ( +
{`count:${this.state.count}, other:${this.state.other}`}
+ ); + } + }, + ); + + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render(); + expect(result).toEqual(
count:3, other:foobar
); + }); + + it('can setState in componentWillMount when shallow rendering', () => { + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + UNSAFE_componentWillMount() { + this.setState({groovy: 'doovy'}); + } + + render() { + return
{this.state.groovy}
; + } + }, + ); + + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render(); + expect(result).toEqual(
doovy
); + }); + + it('can setState in componentWillMount repeatedly when shallow rendering', () => { + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + state = { + separator: '-', + }; + + UNSAFE_componentWillMount() { + this.setState({groovy: 'doovy'}); + this.setState({doovy: 'groovy'}); + } + + render() { + const {groovy, doovy, separator} = this.state; + + return
{`${groovy}${separator}${doovy}`}
; + } + }, + ); + + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render(); + expect(result).toEqual(
doovy-groovy
); + }); + + it('can setState in componentWillMount with an updater function repeatedly when shallow rendering', () => { + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + state = { + separator: '-', + }; + + UNSAFE_componentWillMount() { + this.setState(state => ({groovy: 'doovy'})); + this.setState(state => ({doovy: state.groovy})); + } + + render() { + const {groovy, doovy, separator} = this.state; + + return
{`${groovy}${separator}${doovy}`}
; + } + }, + ); + + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render(); + expect(result).toEqual(
doovy-doovy
); + }); + + it('can setState in componentWillReceiveProps when shallow rendering', () => { + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + state = {count: 0}; + + UNSAFE_componentWillReceiveProps(nextProps) { + if (nextProps.updateState) { + this.setState({count: 1}); + } + } + + render() { + return
{this.state.count}
; + } + }, + ); + + const shallowRenderer = createRenderer(); + let result = shallowRenderer.render( + , + ); + expect(result.props.children).toEqual(0); + + result = shallowRenderer.render(); + expect(result.props.children).toEqual(1); + }); + + it('can update state with static getDerivedStateFromProps when shallow rendering', () => { + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + state = {count: 1}; + + static getDerivedStateFromProps(nextProps, prevState) { + if (nextProps.updateState) { + return {count: nextProps.incrementBy + prevState.count}; + } + + return null; + } + + render() { + return
{this.state.count}
; + } + }, + ); + + const shallowRenderer = createRenderer(); + let result = shallowRenderer.render( + , + ); + expect(result.props.children).toEqual(1); + + result = shallowRenderer.render( + , + ); + expect(result.props.children).toEqual(3); + + result = shallowRenderer.render( + , + ); + expect(result.props.children).toEqual(3); + }); + + it('should not override state with stale values if prevState is spread within getDerivedStateFromProps', () => { + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + state = {value: 0}; + + static getDerivedStateFromProps(nextProps, prevState) { + return {...prevState}; + } + + updateState = () => { + this.setState(state => ({value: state.value + 1})); + }; + + render() { + return
{`value:${this.state.value}`}
; + } + }, + ); + + const shallowRenderer = createRenderer(); + let result = shallowRenderer.render(); + expect(result).toEqual(
value:0
); + + let instance = shallowRenderer.getMountedInstance(); + instance.updateState(); + result = shallowRenderer.getRenderOutput(); + expect(result).toEqual(
value:1
); + }); + + it('should pass previous state to shouldComponentUpdate even with getDerivedStateFromProps', () => { + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + constructor(props) { + super(props); + this.state = { + value: props.value, + }; + } + + static getDerivedStateFromProps(nextProps, prevState) { + if (nextProps.value === prevState.value) { + return null; + } + return {value: nextProps.value}; + } + + shouldComponentUpdate(nextProps, nextState) { + return nextState.value !== this.state.value; + } + + render() { + return
{`value:${this.state.value}`}
; + } + }, + ); + + const shallowRenderer = createRenderer(); + const initialResult = shallowRenderer.render( + , + ); + expect(initialResult).toEqual(
value:initial
); + const updatedResult = shallowRenderer.render( + , + ); + expect(updatedResult).toEqual(
value:updated
); + }); + + it('can setState with an updater function', () => { + let instance; + + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + state = { + counter: 0, + }; + + render() { + instance = this; + return ( + + ); + } + }, + ); + + const shallowRenderer = createRenderer(); + let result = shallowRenderer.render(); + expect(result.props.children).toEqual(0); + + instance.setState((state, props) => { + return {counter: props.defaultCount + 1}; + }); + + result = shallowRenderer.getRenderOutput(); + expect(result.props.children).toEqual(2); + }); + + it('can access component instance from setState updater function', done => { + let instance; + + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + state = {}; + + render() { + instance = this; + return null; + } + }, + ); + + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + + instance.setState(function updater(state, props) { + expect(this).toBe(instance); + done(); + }); + }); + + it('can setState with a callback', () => { + let instance; + + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + state = { + counter: 0, + }; + render() { + instance = this; + return

{this.state.counter}

; + } + }, + ); + + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render(); + expect(result.props.children).toBe(0); + + const callback = jest.fn(function() { + expect(this).toBe(instance); + }); + + instance.setState({counter: 1}, callback); + + const updated = shallowRenderer.getRenderOutput(); + expect(updated.props.children).toBe(1); + expect(callback).toHaveBeenCalled(); + }); + + it('can replaceState with a callback', () => { + let instance; + + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + state = { + counter: 0, + }; + render() { + instance = this; + return

{this.state.counter}

; + } + }, + ); + + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render(); + expect(result.props.children).toBe(0); + + const callback = jest.fn(function() { + expect(this).toBe(instance); + }); + + // No longer a public API, but we can test that it works internally by + // reaching into the updater. + shallowRenderer._updater.enqueueReplaceState( + instance, + {counter: 1}, + callback, + ); + + const updated = shallowRenderer.getRenderOutput(); + expect(updated.props.children).toBe(1); + expect(callback).toHaveBeenCalled(); + }); + + it('can forceUpdate with a callback', () => { + let instance; + + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + state = { + counter: 0, + }; + render() { + instance = this; + return

{this.state.counter}

; + } + }, + ); + + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render(); + expect(result.props.children).toBe(0); + + const callback = jest.fn(function() { + expect(this).toBe(instance); + }); + + instance.forceUpdate(callback); + + const updated = shallowRenderer.getRenderOutput(); + expect(updated.props.children).toBe(0); + expect(callback).toHaveBeenCalled(); + }); + + it('can pass context when shallowly rendering', () => { + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + static contextTypes = { + name: PropTypes.string, + }; + + render() { + return
{this.context.name}
; + } + }, + ); + + const shallowRenderer = createRenderer(); + const result = shallowRenderer.render(, { + name: 'foo', + }); + expect(result).toEqual(
foo
); + }); + + it('should track context across updates', () => { + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + static contextTypes = { + foo: PropTypes.string, + }; + + state = { + bar: 'bar', + }; + + render() { + return
{`${this.context.foo}:${this.state.bar}`}
; + } + }, + ); + + const shallowRenderer = createRenderer(); + let result = shallowRenderer.render(, { + foo: 'foo', + }); + expect(result).toEqual(
foo:bar
); + + const instance = shallowRenderer.getMountedInstance(); + instance.setState({bar: 'baz'}); + + result = shallowRenderer.getRenderOutput(); + expect(result).toEqual(
foo:baz
); + }); + + it('should filter context by contextTypes', () => { + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + static contextTypes = { + foo: PropTypes.string, + }; + render() { + return
{`${this.context.foo}:${this.context.bar}`}
; + } + }, + ); + + const shallowRenderer = createRenderer(); + let result = shallowRenderer.render(, { + foo: 'foo', + bar: 'bar', + }); + expect(result).toEqual(
foo:undefined
); + }); + + it('can fail context when shallowly rendering', () => { + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + static contextTypes = { + name: PropTypes.string.isRequired, + }; + + render() { + return
{this.context.name}
; + } + }, + ); + + const shallowRenderer = createRenderer(); + expect(() => shallowRenderer.render()).toWarnDev( + 'Warning: Failed context type: The context `name` is marked as ' + + 'required in `SimpleComponent`, but its value is `undefined`.\n' + + ' in SimpleComponent (at **)', + ); + }); + + it('should warn about propTypes (but only once)', () => { + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + static propTypes = { + name: PropTypes.string.isRequired, + }; + + render() { + return React.createElement('div', null, this.props.name); + } + }, + ); + + const shallowRenderer = createRenderer(); + expect(() => + shallowRenderer.render(React.createElement(SimpleComponent, {name: 123})), + ).toWarnDev( + 'Warning: Failed prop type: Invalid prop `name` of type `number` ' + + 'supplied to `SimpleComponent`, expected `string`.\n' + + ' in SimpleComponent', + ); + }); + + it('should enable rendering of cloned element', () => { + const SimpleComponent = React.memo( + class SimpleComponent extends React.Component { + constructor(props) { + super(props); + + this.state = { + bar: 'bar', + }; + } + + render() { + return
{`${this.props.foo}:${this.state.bar}`}
; + } + }, + ); + + const shallowRenderer = createRenderer(); + const el = ; + let result = shallowRenderer.render(el); + expect(result).toEqual(
foo:bar
); + + const cloned = React.cloneElement(el, {foo: 'baz'}); + result = shallowRenderer.render(cloned); + expect(result).toEqual(
baz:bar
); + }); + + it('this.state should be updated on setState callback inside componentWillMount', () => { + let stateSuccessfullyUpdated = false; + + const Component = React.memo( + class Component extends React.Component { + constructor(props, context) { + super(props, context); + this.state = { + hasUpdatedState: false, + }; + } + + UNSAFE_componentWillMount() { + this.setState( + {hasUpdatedState: true}, + () => (stateSuccessfullyUpdated = this.state.hasUpdatedState), + ); + } + + render() { + return
{this.props.children}
; + } + }, + ); + + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + expect(stateSuccessfullyUpdated).toBe(true); + }); + + it('should handle multiple callbacks', () => { + const mockFn = jest.fn(); + const shallowRenderer = createRenderer(); + + const Component = React.memo( + class Component extends React.Component { + constructor(props, context) { + super(props, context); + this.state = { + foo: 'foo', + }; + } + + UNSAFE_componentWillMount() { + this.setState({foo: 'bar'}, () => mockFn()); + this.setState({foo: 'foobar'}, () => mockFn()); + } + + render() { + return
{this.state.foo}
; + } + }, + ); + + shallowRenderer.render(); + + expect(mockFn).toHaveBeenCalledTimes(2); + + // Ensure the callback queue is cleared after the callbacks are invoked + const mountedInstance = shallowRenderer.getMountedInstance(); + mountedInstance.setState({foo: 'bar'}, () => mockFn()); + expect(mockFn).toHaveBeenCalledTimes(3); + }); + + it('should call the setState callback even if shouldComponentUpdate = false', done => { + const mockFn = jest.fn().mockReturnValue(false); + + const Component = React.memo( + class Component extends React.Component { + constructor(props, context) { + super(props, context); + this.state = { + hasUpdatedState: false, + }; + } + + shouldComponentUpdate() { + return mockFn(); + } + + render() { + return
{this.state.hasUpdatedState}
; + } + }, + ); + + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + + const mountedInstance = shallowRenderer.getMountedInstance(); + mountedInstance.setState({hasUpdatedState: true}, () => { + expect(mockFn).toBeCalled(); + expect(mountedInstance.state.hasUpdatedState).toBe(true); + done(); + }); + }); + + it('throws usefully when rendering badly-typed elements', () => { + const shallowRenderer = createRenderer(); + + const renderAndVerifyWarningAndError = (Component, typeString) => { + expect(() => { + expect(() => shallowRenderer.render()).toWarnDev( + 'React.createElement: type is invalid -- expected a string ' + + '(for built-in components) or a class/function (for composite components) ' + + `but got: ${typeString}.`, + ); + }).toThrowError( + 'ReactShallowRenderer render(): Shallow rendering works only with custom ' + + `components, but the provided element type was \`${typeString}\`.`, + ); + }; + + renderAndVerifyWarningAndError(undefined, 'undefined'); + renderAndVerifyWarningAndError(null, 'null'); + renderAndVerifyWarningAndError([], 'array'); + renderAndVerifyWarningAndError({}, 'object'); + }); + + it('should have initial state of null if not defined', () => { + const SomeComponent = React.memo( + class SomeComponent extends React.Component { + render() { + return ; + } + }, + ); + + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + + expect(shallowRenderer.getMountedInstance().state).toBeNull(); + }); + + it('should invoke both deprecated and new lifecycles if both are present', () => { + const log = []; + + const Component = React.memo( + class Component extends React.Component { + componentWillMount() { + log.push('componentWillMount'); + } + componentWillReceiveProps() { + log.push('componentWillReceiveProps'); + } + componentWillUpdate() { + log.push('componentWillUpdate'); + } + UNSAFE_componentWillMount() { + log.push('UNSAFE_componentWillMount'); + } + UNSAFE_componentWillReceiveProps() { + log.push('UNSAFE_componentWillReceiveProps'); + } + UNSAFE_componentWillUpdate() { + log.push('UNSAFE_componentWillUpdate'); + } + render() { + return null; + } + }, + ); + + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + expect(log).toEqual(['componentWillMount', 'UNSAFE_componentWillMount']); + + log.length = 0; + + shallowRenderer.render(); + expect(log).toEqual([ + 'componentWillReceiveProps', + 'UNSAFE_componentWillReceiveProps', + 'componentWillUpdate', + 'UNSAFE_componentWillUpdate', + ]); + }); + + it('should stop the update when setState returns null or undefined', () => { + const log = []; + let instance; + const Component = React.memo( + class Component extends React.Component { + constructor(props) { + super(props); + this.state = { + count: 0, + }; + } + render() { + log.push('render'); + instance = this; + return null; + } + }, + ); + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + log.length = 0; + instance.setState(() => null); + instance.setState(() => undefined); + instance.setState(null); + instance.setState(undefined); + expect(log).toEqual([]); + instance.setState(state => ({count: state.count + 1})); + expect(log).toEqual(['render']); + }); + + it('should not get this in a function component', () => { + const logs = []; + const Foo = React.memo(function Foo() { + logs.push(this); + return
foo
; + }); + const shallowRenderer = createRenderer(); + shallowRenderer.render(); + expect(logs).toEqual([undefined]); + }); +});