Skip to content

Commit 6b51df7

Browse files
koba04ljharb
authored andcommitted
[Fix] shallow: skip updates when nextState is null or undefined
- related: facebook/react#12756 Fixes enzymejs#1783.
1 parent 8bc3635 commit 6b51df7

File tree

3 files changed

+143
-3
lines changed

3 files changed

+143
-3
lines changed

packages/enzyme-test-suite/test/ReactWrapper-spec.jsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2460,6 +2460,72 @@ describeWithDOM('mount', () => {
24602460
});
24612461
});
24622462

2463+
it('prevents the update if nextState is null or undefined', () => {
2464+
class Foo extends React.Component {
2465+
constructor(props) {
2466+
super(props);
2467+
this.state = { id: 'foo' };
2468+
}
2469+
2470+
componentDidUpdate() {}
2471+
2472+
render() {
2473+
return (
2474+
<div className={this.state.id} />
2475+
);
2476+
}
2477+
}
2478+
2479+
const wrapper = mount(<Foo />);
2480+
const spy = sinon.spy(wrapper.instance(), 'componentDidUpdate');
2481+
const callback = sinon.spy();
2482+
wrapper.setState(() => ({ id: 'bar' }), callback);
2483+
expect(spy).to.have.property('callCount', 1);
2484+
expect(callback).to.have.property('callCount', 1);
2485+
2486+
wrapper.setState(() => null, callback);
2487+
expect(spy).to.have.property('callCount', 1);
2488+
// the callback should always be called
2489+
expect(callback).to.have.property('callCount', 2);
2490+
2491+
wrapper.setState(() => undefined, callback);
2492+
expect(spy).to.have.property('callCount', 1);
2493+
expect(callback).to.have.property('callCount', 3);
2494+
});
2495+
2496+
it('prevents an infinite loop if nextState is null or undefined from setState in CDU', () => {
2497+
class Foo extends React.Component {
2498+
constructor(props) {
2499+
super(props);
2500+
this.state = { id: 'foo' };
2501+
}
2502+
2503+
componentDidUpdate() {
2504+
// eslint-disable-next-line react/no-did-update-set-state
2505+
this.setState(() => null);
2506+
}
2507+
2508+
render() {
2509+
return (
2510+
<div className={this.state.id} />
2511+
);
2512+
}
2513+
}
2514+
2515+
let payload;
2516+
const stub = sinon.stub(Foo.prototype, 'componentDidUpdate')
2517+
.callsFake(function componentDidUpdate() { this.setState(() => payload); });
2518+
2519+
const wrapper = mount(<Foo />);
2520+
2521+
wrapper.setState(() => ({ id: 'bar' }));
2522+
expect(stub).to.have.property('callCount', 1);
2523+
2524+
payload = null;
2525+
wrapper.setState(() => ({ id: 'bar' }));
2526+
expect(stub).to.have.property('callCount', 2);
2527+
});
2528+
24632529
describe('should not call componentWillReceiveProps after setState is called', () => {
24642530
it('should not call componentWillReceiveProps upon rerender', () => {
24652531
class A extends React.Component {

packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2406,6 +2406,69 @@ describe('shallow', () => {
24062406
});
24072407
});
24082408

2409+
it('prevents the update if nextState is null or undefined', () => {
2410+
class Foo extends React.Component {
2411+
constructor(props) {
2412+
super(props);
2413+
this.state = { id: 'foo' };
2414+
}
2415+
2416+
componentDidUpdate() {}
2417+
2418+
render() {
2419+
return (
2420+
<div className={this.state.id} />
2421+
);
2422+
}
2423+
}
2424+
2425+
const wrapper = shallow(<Foo />);
2426+
const spy = sinon.spy(wrapper.instance(), 'componentDidUpdate');
2427+
const callback = sinon.spy();
2428+
wrapper.setState(() => ({ id: 'bar' }), callback);
2429+
expect(spy).to.have.property('callCount', 1);
2430+
expect(callback).to.have.property('callCount', 1);
2431+
2432+
wrapper.setState(() => null, callback);
2433+
expect(spy).to.have.property('callCount', 1);
2434+
// the callback should always be called
2435+
expect(callback).to.have.property('callCount', 2);
2436+
2437+
wrapper.setState(() => undefined, callback);
2438+
expect(spy).to.have.property('callCount', 1);
2439+
expect(callback).to.have.property('callCount', 3);
2440+
});
2441+
2442+
it('prevents an infinite loop if nextState is null or undefined from setState in CDU', () => {
2443+
class Foo extends React.Component {
2444+
constructor(props) {
2445+
super(props);
2446+
this.state = { id: 'foo' };
2447+
}
2448+
2449+
componentDidUpdate() {}
2450+
2451+
render() {
2452+
return (
2453+
<div className={this.state.id} />
2454+
);
2455+
}
2456+
}
2457+
2458+
let payload;
2459+
const stub = sinon.stub(Foo.prototype, 'componentDidUpdate')
2460+
.callsFake(function componentDidUpdate() { this.setState(() => payload); });
2461+
2462+
const wrapper = shallow(<Foo />);
2463+
2464+
wrapper.setState(() => ({ id: 'bar' }));
2465+
expect(stub).to.have.property('callCount', 1);
2466+
2467+
payload = null;
2468+
wrapper.setState(() => ({ id: 'bar' }));
2469+
expect(stub).to.have.property('callCount', 2);
2470+
});
2471+
24092472
describe('should not call componentWillReceiveProps after setState is called', () => {
24102473
it('should not call componentWillReceiveProps upon rerender', () => {
24112474
class A extends React.Component {

packages/enzyme/src/ShallowWrapper.js

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,7 @@ class ShallowWrapper {
436436
if (arguments.length > 1 && typeof callback !== 'function') {
437437
throw new TypeError('ReactWrapper::setState() expects a function as its second argument');
438438
}
439+
439440
this.single('setState', () => {
440441
withSetStateAllowed(() => {
441442
const adapter = getAdapter(this[OPTIONS]);
@@ -446,6 +447,15 @@ class ShallowWrapper {
446447
const prevProps = instance.props;
447448
const prevState = instance.state;
448449
const prevContext = instance.context;
450+
451+
const statePayload = typeof state === 'function'
452+
? state.call(instance, prevState, prevProps)
453+
: state;
454+
455+
// returning null or undefined prevents the update
456+
// https://github.com/facebook/react/pull/12756
457+
const maybeHasUpdate = statePayload != null;
458+
449459
// When shouldComponentUpdate returns false we shouldn't call componentDidUpdate.
450460
// so we spy shouldComponentUpdate to get the result.
451461
let spy;
@@ -462,16 +472,17 @@ class ShallowWrapper {
462472
// We don't pass the setState callback here
463473
// to guarantee to call the callback after finishing the render
464474
if (instance[SET_STATE]) {
465-
instance[SET_STATE](state);
475+
instance[SET_STATE](statePayload);
466476
} else {
467-
instance.setState(state);
477+
instance.setState(statePayload);
468478
}
469479
if (spy) {
470480
shouldRender = spy.getLastReturnValue();
471481
spy.restore();
472482
}
473483
if (
474-
shouldRender
484+
maybeHasUpdate
485+
&& shouldRender
475486
&& !this[OPTIONS].disableLifecycleMethods
476487
&& lifecycles.componentDidUpdate
477488
&& lifecycles.componentDidUpdate.onSetState

0 commit comments

Comments
 (0)