Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Fix] shallow: ensure SCU is called after GDSFP returns null #1981

Merged
merged 4 commits into from Mar 12, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -251,7 +251,9 @@ class ReactSixteenThreeAdapter extends EnzymeAdapter {
componentDidUpdate: {
onSetState: true,
},
getDerivedStateFromProps: true,
getDerivedStateFromProps: {
hasShouldComponentUpdateBug: true,
},
getSnapshotBeforeUpdate: true,
setState: {
skipsComponentDidUpdateOnNullish: true,
Expand Down
3 changes: 2 additions & 1 deletion packages/enzyme-adapter-react-16/package.json
Expand Up @@ -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",
Expand Down
11 changes: 9 additions & 2 deletions packages/enzyme-adapter-react-16/src/ReactSixteenAdapter.js
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -280,7 +285,7 @@ function getEmptyStateValue() {
}

function wrapAct(fn) {
if (typeof TestUtils.act !== 'function') {
if (!is168) {
return fn();
}
let returnVal;
Expand All @@ -301,7 +306,9 @@ class ReactSixteenAdapter extends EnzymeAdapter {
componentDidUpdate: {
onSetState: true,
},
getDerivedStateFromProps: true,
getDerivedStateFromProps: {
hasShouldComponentUpdateBug,
},
getSnapshotBeforeUpdate: true,
setState: {
skipsComponentDidUpdateOnNullish: true,
Expand Down
31 changes: 30 additions & 1 deletion packages/enzyme-test-suite/test/ReactWrapper-spec.jsx
Expand Up @@ -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(<GDSFP {...prevProps} />);
Expand Down Expand Up @@ -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 (<input value={value} />);
}
}
const wrapper = mount(<SimpleComponent value="initial" />);

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', () => {
Expand Down
31 changes: 30 additions & 1 deletion packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx
Expand Up @@ -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(<GDSFP {...prevProps} />);
Expand Down Expand Up @@ -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 (<input value={value} />);
}
}
const wrapper = shallow(<SimpleComponent value="initial" />);

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', () => {
Expand Down
21 changes: 21 additions & 0 deletions packages/enzyme-test-suite/test/Utils-spec.jsx
Expand Up @@ -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()', () => {
Expand Down
37 changes: 37 additions & 0 deletions packages/enzyme/src/ShallowWrapper.js
Expand Up @@ -131,6 +131,10 @@ function getAdapterLifecycles({ options }) {
}),
}
: null;
const { getDerivedStateFromProps: originalGDSFP } = lifecycles;
const getDerivedStateFromProps = originalGDSFP ? {
hasShouldComponentUpdateBug: !!originalGDSFP.hasShouldComponentUpdateBug,
} : false;

return {
...lifecycles,
Expand All @@ -142,6 +146,7 @@ function getAdapterLifecycles({ options }) {
...lifecycles.getChildContext,
},
...(componentDidUpdate && { componentDidUpdate }),
getDerivedStateFromProps,
};
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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 (
Expand Down
4 changes: 2 additions & 2 deletions packages/enzyme/src/Utils.js
Expand Up @@ -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);
Expand All @@ -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;
Expand Down