diff --git a/packages/enzyme-test-suite/package.json b/packages/enzyme-test-suite/package.json index fc118bd50..597e25d66 100644 --- a/packages/enzyme-test-suite/package.json +++ b/packages/enzyme-test-suite/package.json @@ -33,6 +33,7 @@ "enzyme": "^3.3.0", "enzyme-adapter-utils": "^1.5.0", "jsdom": "^6.5.1", + "lodash.isequal": "^4.5.0", "mocha-wrap": "^2.1.2", "object.assign": "^4.1.0", "object-inspect": "^1.6.0", diff --git a/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx b/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx index 6c30a4df8..abbf38dc8 100644 --- a/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx +++ b/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import { expect } from 'chai'; import sinon from 'sinon'; import wrap from 'mocha-wrap'; +import isEqual from 'lodash.isequal'; import { mount, render, @@ -27,6 +28,7 @@ import { createRef, Fragment, forwardRef, + PureComponent, } from './_helpers/react-compat'; import { describeWithDOM, @@ -5128,6 +5130,78 @@ describeWithDOM('mount', () => { }); }); + describeIf(is('>= 15.3'), 'PureComponent', () => { + it('does not update when state and props did not change', () => { + class Foo extends PureComponent { + constructor(props) { + super(props); + this.state = { + foo: 'init', + }; + } + + componentDidUpdate() {} + + render() { + return ( +
+ {this.state.foo} +
+ ); + } + } + const spy = sinon.spy(Foo.prototype, 'componentDidUpdate'); + const wrapper = mount(); + wrapper.setState({ foo: 'update' }); + expect(spy).to.have.property('callCount', 1); + wrapper.setState({ foo: 'update' }); + expect(spy).to.have.property('callCount', 1); + + wrapper.setProps({ id: 2 }); + expect(spy).to.have.property('callCount', 2); + wrapper.setProps({ id: 2 }); + expect(spy).to.have.property('callCount', 2); + }); + }); + + describe('Own PureComponent implementation', () => { + it('does not update when state and props did not change', () => { + class Foo extends React.Component { + constructor(props) { + super(props); + this.state = { + foo: 'init', + }; + } + + shouldComponentUpdate(nextProps, nextState) { + return !isEqual(this.props, nextProps) || !isEqual(this.state, nextState); + } + + componentDidUpdate() {} + + render() { + return ( +
+ {this.state.foo} +
+ ); + } + } + const spy = sinon.spy(Foo.prototype, 'componentDidUpdate'); + const wrapper = mount(); + wrapper.setState({ foo: 'update' }); + expect(spy).to.have.property('callCount', 1); + wrapper.setState({ foo: 'update' }); + expect(spy).to.have.property('callCount', 1); + + wrapper.setProps({ id: 2 }); + expect(spy).to.have.property('callCount', 2); + wrapper.setProps({ id: 2 }); + expect(spy).to.have.property('callCount', 2); + }); + }); + describeIf(is('>= 16.3'), 'support getSnapshotBeforeUpdate', () => { it('calls getSnapshotBeforeUpdate and pass snapshot to componentDidUpdate', () => { const spy = sinon.spy(); diff --git a/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx b/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx index 92a0b9e47..c561b2bbb 100644 --- a/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx +++ b/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { expect } from 'chai'; import sinon from 'sinon'; import wrap from 'mocha-wrap'; +import isEqual from 'lodash.isequal'; import { shallow, render, @@ -27,6 +28,7 @@ import { createRef, Fragment, forwardRef, + PureComponent, } from './_helpers/react-compat'; import { describeIf, @@ -5499,6 +5501,78 @@ describe('shallow', () => { }); }); + describeIf(is('>= 15.3'), 'PureComponent', () => { + it('does not update when state and props did not change', () => { + class Foo extends PureComponent { + constructor(props) { + super(props); + this.state = { + foo: 'init', + }; + } + + componentDidUpdate() {} + + render() { + return ( +
+ {this.state.foo} +
+ ); + } + } + const spy = sinon.spy(Foo.prototype, 'componentDidUpdate'); + const wrapper = shallow(); + wrapper.setState({ foo: 'update' }); + expect(spy).to.have.property('callCount', 1); + wrapper.setState({ foo: 'update' }); + expect(spy).to.have.property('callCount', 1); + + wrapper.setProps({ id: 2 }); + expect(spy).to.have.property('callCount', 2); + wrapper.setProps({ id: 2 }); + expect(spy).to.have.property('callCount', 2); + }); + }); + + describe('Own PureComponent implementation', () => { + it('does not update when state and props did not change', () => { + class Foo extends React.Component { + constructor(props) { + super(props); + this.state = { + foo: 'init', + }; + } + + shouldComponentUpdate(nextProps, nextState) { + return !isEqual(this.props, nextProps) || !isEqual(this.state, nextState); + } + + componentDidUpdate() {} + + render() { + return ( +
+ {this.state.foo} +
+ ); + } + } + const spy = sinon.spy(Foo.prototype, 'componentDidUpdate'); + const wrapper = mount(); + wrapper.setState({ foo: 'update' }); + expect(spy).to.have.property('callCount', 1); + wrapper.setState({ foo: 'update' }); + expect(spy).to.have.property('callCount', 1); + + wrapper.setProps({ id: 2 }); + expect(spy).to.have.property('callCount', 2); + wrapper.setProps({ id: 2 }); + expect(spy).to.have.property('callCount', 2); + }); + }); + describeIf(is('>= 16.3'), 'support getSnapshotBeforeUpdate', () => { it('calls getSnapshotBeforeUpdate and pass snapshot to componentDidUpdate', () => { const spy = sinon.spy(); diff --git a/packages/enzyme-test-suite/test/_helpers/react-compat.js b/packages/enzyme-test-suite/test/_helpers/react-compat.js index 3b36c3847..7fb9d72df 100644 --- a/packages/enzyme-test-suite/test/_helpers/react-compat.js +++ b/packages/enzyme-test-suite/test/_helpers/react-compat.js @@ -16,6 +16,7 @@ let Fragment; let StrictMode; let AsyncMode; let Profiler; +let PureComponent; if (is('>=15.5 || ^16.0.0-alpha || ^16.3.0-alpha')) { // eslint-disable-next-line import/no-extraneous-dependencies @@ -37,6 +38,12 @@ if (is('^16.0.0-0 || ^16.3.0-0')) { createPortal = null; } +if (is('>=15.3')) { + ({ PureComponent } = require('react')); +} else { + PureComponent = null; +} + if (is('^16.2.0-0')) { ({ Fragment } = require('react')); } else { @@ -78,4 +85,5 @@ export { StrictMode, AsyncMode, Profiler, + PureComponent, }; diff --git a/packages/enzyme/src/ShallowWrapper.js b/packages/enzyme/src/ShallowWrapper.js index 8160100b9..204aabd7c 100644 --- a/packages/enzyme/src/ShallowWrapper.js +++ b/packages/enzyme/src/ShallowWrapper.js @@ -158,6 +158,14 @@ function privateSetNodes(wrapper, nodes) { privateSet(wrapper, 'length', wrapper[NODES].length); } +function pureComponentShouldComponentUpdate(prevProps, props, prevState, state) { + return !isEqual(prevProps, props) || !isEqual(prevState, state); +} + +function isPureComponent(instance) { + return instance && instance.isPureReactComponent; +} + /** * @class ShallowWrapper */ @@ -342,6 +350,13 @@ class ShallowWrapper { && typeof instance.shouldComponentUpdate === 'function' ) { spy = spyMethod(instance, 'shouldComponentUpdate'); + } else if (isPureComponent(instance)) { + shouldRender = pureComponentShouldComponentUpdate( + prevProps, + props, + state, + instance.state, + ); } if (props) this[UNRENDERED] = cloneElement(adapter, this[UNRENDERED], props); this[RENDERER].render(this[UNRENDERED], nextContext); @@ -472,6 +487,13 @@ class ShallowWrapper { && typeof instance.shouldComponentUpdate === 'function' ) { spy = spyMethod(instance, 'shouldComponentUpdate'); + } else if (isPureComponent(instance)) { + shouldRender = pureComponentShouldComponentUpdate( + prevProps, + instance.props, + prevState, + statePayload, + ); } // We don't pass the setState callback here // to guarantee to call the callback after finishing the render