diff --git a/packages/enzyme-adapter-react-16.3/src/ReactSixteenThreeAdapter.js b/packages/enzyme-adapter-react-16.3/src/ReactSixteenThreeAdapter.js index 6645a781e..d4a119ec8 100644 --- a/packages/enzyme-adapter-react-16.3/src/ReactSixteenThreeAdapter.js +++ b/packages/enzyme-adapter-react-16.3/src/ReactSixteenThreeAdapter.js @@ -251,7 +251,9 @@ class ReactSixteenThreeAdapter extends EnzymeAdapter { componentDidUpdate: { onSetState: true, }, - getDerivedStateFromProps: true, + getDerivedStateFromProps: { + hasShouldComponentUpdateBug: true, + }, getSnapshotBeforeUpdate: true, setState: { skipsComponentDidUpdateOnNullish: true, diff --git a/packages/enzyme-adapter-react-16/package.json b/packages/enzyme-adapter-react-16/package.json index 59559385b..266792c50 100644 --- a/packages/enzyme-adapter-react-16/package.json +++ b/packages/enzyme-adapter-react-16/package.json @@ -40,7 +40,8 @@ "object.values": "^1.1.0", "prop-types": "^15.7.2", "react-is": "^16.7.0", - "react-test-renderer": "^16.0.0-0" + "react-test-renderer": "^16.0.0-0", + "semver": "^5.6.0" }, "peerDependencies": { "enzyme": "^3.0.0", diff --git a/packages/enzyme-adapter-react-16/src/ReactSixteenAdapter.js b/packages/enzyme-adapter-react-16/src/ReactSixteenAdapter.js index 1ef9d011e..a5053ded0 100644 --- a/packages/enzyme-adapter-react-16/src/ReactSixteenAdapter.js +++ b/packages/enzyme-adapter-react-16/src/ReactSixteenAdapter.js @@ -5,8 +5,10 @@ import ReactDOM from 'react-dom'; import ReactDOMServer from 'react-dom/server'; // eslint-disable-next-line import/no-unresolved import ShallowRenderer from 'react-test-renderer/shallow'; +import { version as testRendererVersion } from 'react-test-renderer/package'; // eslint-disable-next-line import/no-unresolved import TestUtils from 'react-dom/test-utils'; +import semver from 'semver'; import checkPropTypes from 'prop-types/checkPropTypes'; import { isElement, @@ -50,6 +52,9 @@ import detectFiberTags from './detectFiberTags'; const is164 = !!TestUtils.Simulate.touchStart; // 16.4+ const is165 = !!TestUtils.Simulate.auxClick; // 16.5+ const is166 = is165 && !React.unstable_AsyncMode; // 16.6+ +const is168 = is166 && typeof TestUtils.act === 'function'; + +const hasShouldComponentUpdateBug = semver.satisfies(testRendererVersion, '< 16.8'); // Lazily populated if DOM is available. let FiberTags = null; @@ -280,7 +285,7 @@ function getEmptyStateValue() { } function wrapAct(fn) { - if (typeof TestUtils.act !== 'function') { + if (!is168) { return fn(); } let returnVal; @@ -301,7 +306,9 @@ class ReactSixteenAdapter extends EnzymeAdapter { componentDidUpdate: { onSetState: true, }, - getDerivedStateFromProps: true, + getDerivedStateFromProps: { + hasShouldComponentUpdateBug, + }, getSnapshotBeforeUpdate: true, setState: { skipsComponentDidUpdateOnNullish: true, diff --git a/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx b/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx index 9c84ddd8c..d23c3df93 100644 --- a/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx +++ b/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx @@ -5774,7 +5774,7 @@ describeWithDOM('mount', () => { ]); }); - it('calls GDSFP when expected', () => { + it('calls gDSFP when expected', () => { const prevProps = { a: 1 }; const state = { state: true }; const wrapper = mount(); @@ -5821,6 +5821,35 @@ describeWithDOM('mount', () => { }], ]); }); + + it('cDU’s nextState differs from `this.state` when gDSFP returns new state', () => { + class SimpleComponent extends React.Component { + constructor(props) { + super(props); + this.state = { value: props.value }; + } + + static getDerivedStateFromProps(props, state) { + return props.value === state.value ? null : { value: props.value }; + } + + shouldComponentUpdate(nextProps, nextState) { + return nextState.value !== this.state.value; + } + + render() { + const { value } = this.state; + return (); + } + } + const wrapper = mount(); + + expect(wrapper.find('input').prop('value')).to.equal('initial'); + + wrapper.setProps({ value: 'updated' }); + + expect(wrapper.find('input').prop('value')).to.equal('updated'); + }); }); describeIf(is('>= 16'), 'componentDidCatch', () => { diff --git a/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx b/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx index e134abe72..86459e854 100644 --- a/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx +++ b/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx @@ -6114,7 +6114,7 @@ describe('shallow', () => { ]); }); - it('calls GDSFP when expected', () => { + it('calls gDSFP when expected', () => { const prevProps = { a: 1 }; const state = { state: true }; const wrapper = shallow(); @@ -6161,6 +6161,35 @@ describe('shallow', () => { }], ]); }); + + it('cDU’s nextState differs from `this.state` when gDSFP returns new state', () => { + class SimpleComponent extends React.Component { + constructor(props) { + super(props); + this.state = { value: props.value }; + } + + static getDerivedStateFromProps(props, state) { + return props.value === state.value ? null : { value: props.value }; + } + + shouldComponentUpdate(nextProps, nextState) { + return nextState.value !== this.state.value; + } + + render() { + const { value } = this.state; + return (); + } + } + const wrapper = shallow(); + + expect(wrapper.find('input').prop('value')).to.equal('initial'); + + wrapper.setProps({ value: 'updated' }); + + expect(wrapper.find('input').prop('value')).to.equal('updated'); + }); }); describeIf(is('>= 16'), 'componentDidCatch', () => { diff --git a/packages/enzyme-test-suite/test/Utils-spec.jsx b/packages/enzyme-test-suite/test/Utils-spec.jsx index 2b9dc3f16..c4245efd2 100644 --- a/packages/enzyme-test-suite/test/Utils-spec.jsx +++ b/packages/enzyme-test-suite/test/Utils-spec.jsx @@ -787,6 +787,27 @@ describe('Utils', () => { spy.restore(); expect(Object.getOwnPropertyDescriptor(obj, 'method')).to.deep.equal(descriptor); }); + + it('accepts an optional `getStub` argument', () => { + const obj = {}; + const descriptor = { + configurable: true, + enumerable: true, + writable: true, + value: () => {}, + }; + Object.defineProperty(obj, 'method', descriptor); + let stub; + let original; + spyMethod(obj, 'method', (originalMethod) => { + original = originalMethod; + stub = () => { throw new EvalError('stubbed'); }; + return stub; + }); + expect(original).to.equal(descriptor.value); + expect(obj).to.have.property('method', stub); + expect(() => obj.method()).to.throw(EvalError); + }); }); describe('isCustomComponentElement()', () => { diff --git a/packages/enzyme/src/ShallowWrapper.js b/packages/enzyme/src/ShallowWrapper.js index b0165110e..fca3632d6 100644 --- a/packages/enzyme/src/ShallowWrapper.js +++ b/packages/enzyme/src/ShallowWrapper.js @@ -131,6 +131,10 @@ function getAdapterLifecycles({ options }) { }), } : null; + const { getDerivedStateFromProps: originalGDSFP } = lifecycles; + const getDerivedStateFromProps = originalGDSFP ? { + hasShouldComponentUpdateBug: !!originalGDSFP.hasShouldComponentUpdateBug, + } : false; return { ...lifecycles, @@ -142,6 +146,7 @@ function getAdapterLifecycles({ options }) { ...lifecycles.getChildContext, }, ...(componentDidUpdate && { componentDidUpdate }), + getDerivedStateFromProps, }; } @@ -243,6 +248,30 @@ function privateSetChildContext(adapter, wrapper, instance, renderedNode, getChi } } +function mockSCUIfgDSFPReturnNonNull(node, state) { + const { getDerivedStateFromProps } = node.type; + + if (typeof getDerivedStateFromProps === 'function') { + // we try to fix a React shallow renderer bug here. + // (facebook/react#14607, which has been fixed in react 16.8): + // when gDSFP return derived state, it will set instance state in shallow renderer before SCU, + // this will cause `this.state` in sCU be the updated state, which is wrong behavior. + // so we have to wrap sCU to pass the old state to original sCU. + const { instance } = node; + const { restore } = spyMethod( + instance, + 'shouldComponentUpdate', + originalSCU => function shouldComponentUpdate(...args) { + instance.state = state; + const sCUResult = originalSCU.apply(instance, args); + const [, nextState] = args; + instance.state = nextState; + restore(); + return sCUResult; + }, + ); + } +} /** * @class ShallowWrapper @@ -452,6 +481,10 @@ class ShallowWrapper { && instance ) { if (typeof instance.shouldComponentUpdate === 'function') { + const { getDerivedStateFromProps: gDSFP } = lifecycles; + if (gDSFP && gDSFP.hasShouldComponentUpdateBug) { + mockSCUIfgDSFPReturnNonNull(node, state); + } shouldComponentUpdateSpy = spyMethod(instance, 'shouldComponentUpdate'); } if ( @@ -601,6 +634,10 @@ class ShallowWrapper { && lifecycles.componentDidUpdate.onSetState && typeof instance.shouldComponentUpdate === 'function' ) { + const { getDerivedStateFromProps: gDSFP } = lifecycles; + if (gDSFP && gDSFP.hasShouldComponentUpdateBug) { + mockSCUIfgDSFPReturnNonNull(node, state); + } shouldComponentUpdateSpy = spyMethod(instance, 'shouldComponentUpdate'); } if ( diff --git a/packages/enzyme/src/Utils.js b/packages/enzyme/src/Utils.js index 505f9666d..54f1e3674 100644 --- a/packages/enzyme/src/Utils.js +++ b/packages/enzyme/src/Utils.js @@ -282,7 +282,7 @@ export function cloneElement(adapter, el, props) { ); } -export function spyMethod(instance, methodName) { +export function spyMethod(instance, methodName, getStub = () => {}) { let lastReturnValue; const originalMethod = instance[methodName]; const hasOwn = has(instance, methodName); @@ -293,7 +293,7 @@ export function spyMethod(instance, methodName) { Object.defineProperty(instance, methodName, { configurable: true, enumerable: !descriptor || !!descriptor.enumerable, - value(...args) { + value: getStub(originalMethod) || function spied(...args) { const result = originalMethod.apply(this, args); lastReturnValue = result; return result;