diff --git a/scripts/fiber/tests-failing.txt b/scripts/fiber/tests-failing.txt index 36f8480baf36f..baa893e18cc69 100644 --- a/scripts/fiber/tests-failing.txt +++ b/scripts/fiber/tests-failing.txt @@ -1,6 +1,3 @@ -src/addons/__tests__/ReactComponentWithPureRenderMixin-test.js -* does not do a deep comparison - src/addons/__tests__/ReactFragment-test.js * should throw if a plain object is used as a child * should throw if a plain object even if it is in an owner diff --git a/scripts/fiber/tests-passing.txt b/scripts/fiber/tests-passing.txt index 18c4945ca2a44..7bc1b651a8ecf 100644 --- a/scripts/fiber/tests-passing.txt +++ b/scripts/fiber/tests-passing.txt @@ -36,6 +36,7 @@ scripts/error-codes/__tests__/invertObject-test.js src/addons/__tests__/ReactComponentWithPureRenderMixin-test.js * provides a default shouldComponentUpdate implementation +* does not do a deep comparison src/addons/__tests__/ReactFragment-test.js * warns for numeric keys on objects as children @@ -1142,7 +1143,9 @@ src/renderers/shared/fiber/__tests__/ReactIncremental-test.js * can resume work in a subtree even when a parent bails out * can resume work in a bailed subtree within one pass * can reuse work done after being preempted +* can reuse work that began but did not complete, after being preempted * can reuse work if shouldComponentUpdate is false, after being preempted +* memoizes work even if shouldComponentUpdate returns false * can update in the middle of a tree using setState * can queue multiple state updates * can use updater form of setState @@ -1407,6 +1410,7 @@ src/renderers/shared/shared/__tests__/ReactCompositeComponentState-test.js * should call componentDidUpdate of children first * should batch unmounts * should update state when called from child cWRP +* should merge state when sCU returns false src/renderers/shared/shared/__tests__/ReactEmptyComponent-test.js * should not produce child DOM nodes for null and false diff --git a/src/renderers/shared/fiber/ReactFiberBeginWork.js b/src/renderers/shared/fiber/ReactFiberBeginWork.js index 814b8cb5f1a04..afe933c0503a6 100644 --- a/src/renderers/shared/fiber/ReactFiberBeginWork.js +++ b/src/renderers/shared/fiber/ReactFiberBeginWork.js @@ -55,7 +55,6 @@ var { OffscreenPriority, } = require('ReactPriorityLevel'); var { - Update, Placement, ContentReset, Err, @@ -91,7 +90,12 @@ module.exports = function( mountClassInstance, resumeMountClassInstance, updateClassInstance, - } = ReactFiberClassComponent(scheduleUpdate, getPriorityContext); + } = ReactFiberClassComponent( + scheduleUpdate, + getPriorityContext, + memoizeProps, + memoizeState, + ); function markChildAsProgressed(current, workInProgress, priorityLevel) { // We now have clones. Let's store them as the currently progressed work. @@ -176,12 +180,13 @@ module.exports = function( // Normally we can bail out on props equality but if context has changed // we don't do the bailout and we have to reuse existing props instead. if (nextChildren === null) { - nextChildren = current && current.memoizedProps; + nextChildren = workInProgress.memoizedProps; } } else if (nextChildren === null || workInProgress.memoizedProps === nextChildren) { return bailoutOnAlreadyFinishedWork(current, workInProgress); } reconcileChildren(current, workInProgress, nextChildren); + memoizeProps(workInProgress, nextChildren); return workInProgress.child; } @@ -202,16 +207,20 @@ module.exports = function( // Normally we can bail out on props equality but if context has changed // we don't do the bailout and we have to reuse existing props instead. if (nextProps === null) { - nextProps = current && current.memoizedProps; + nextProps = memoizedProps; + } + } else { + if (nextProps == null || memoizedProps === nextProps) { + return bailoutOnAlreadyFinishedWork(current, workInProgress); + } + // TODO: Disable this before release, since it is not part of the public API + // I use this for testing to compare the relative overhead of classes. + if (typeof fn.shouldComponentUpdate === 'function' && + !fn.shouldComponentUpdate(memoizedProps, nextProps)) { + // Memoize props even if shouldComponentUpdate returns false + memoizeProps(workInProgress, nextProps); + return bailoutOnAlreadyFinishedWork(current, workInProgress); } - } else if (nextProps === null || memoizedProps === nextProps || ( - // TODO: Disable this before release, since it is not part of the public API - // I use this for testing to compare the relative overhead of classes. - memoizedProps !== null && - typeof fn.shouldComponentUpdate === 'function' && - !fn.shouldComponentUpdate(memoizedProps, nextProps) - )) { - return bailoutOnAlreadyFinishedWork(current, workInProgress); } var unmaskedContext = getUnmaskedContext(workInProgress); @@ -226,6 +235,7 @@ module.exports = function( nextChildren = fn(nextProps, context); } reconcileChildren(current, workInProgress, nextChildren); + memoizeProps(workInProgress, nextProps); return workInProgress.child; } @@ -258,31 +268,23 @@ module.exports = function( shouldUpdate : boolean, hasContext : boolean, ) { - // Schedule side-effects - // Refs should update even if shouldComponentUpdate returns false markRef(current, workInProgress); - if (shouldUpdate) { - workInProgress.effectTag |= Update; - } else { - // If an update was already in progress, we should schedule an Update - // effect even though we're bailing out, so that cWU/cDU are called. - if (current) { - const instance = current.stateNode; - if (instance.props !== current.memoizedProps || - instance.state !== current.memoizedState) { - workInProgress.effectTag |= Update; - } - } + if (!shouldUpdate) { return bailoutOnAlreadyFinishedWork(current, workInProgress); } - // Rerender const instance = workInProgress.stateNode; + + // Rerender ReactCurrentOwner.current = workInProgress; const nextChildren = instance.render(); reconcileChildren(current, workInProgress, nextChildren); + // Memoize props and state using the values we just used to render. + // TODO: Restructure so we never read values from the instance. + memoizeState(workInProgress, instance.state); + memoizeProps(workInProgress, instance.props); // The context might have changed so we need to recalculate it. if (hasContext) { @@ -321,7 +323,7 @@ module.exports = function( } const element = state.element; reconcileChildren(current, workInProgress, element); - workInProgress.memoizedState = state; + memoizeState(workInProgress, state); return workInProgress.child; } // If there is no update queue, that's a bailout because the root has no props. @@ -338,7 +340,7 @@ module.exports = function( // Normally we can bail out on props equality but if context has changed // we don't do the bailout and we have to reuse existing props instead. if (nextProps === null) { - nextProps = prevProps; + nextProps = memoizedProps; if (!nextProps) { throw new Error('We should always have pending or current props.'); } @@ -403,6 +405,7 @@ module.exports = function( // Reconcile the children and stash them for later work. reconcileChildrenAtPriority(current, workInProgress, nextChildren, OffscreenPriority); + memoizeProps(workInProgress, nextProps); workInProgress.child = current ? current.child : null; if (!current) { @@ -422,10 +425,22 @@ module.exports = function( return null; } else { reconcileChildren(current, workInProgress, nextChildren); + memoizeProps(workInProgress, nextProps); return workInProgress.child; } } + function updateHostText(current, workInProgress) { + let nextProps = workInProgress.pendingProps; + if (nextProps === null) { + nextProps = workInProgress.memoizedProps; + } + memoizeProps(workInProgress, nextProps); + // Nothing to do here. This is terminal. We'll do the completion step + // immediately after. + return null; + } + function mountIndeterminateComponent(current, workInProgress, priorityLevel) { if (current) { throw new Error('An indeterminate component should never have mounted.'); @@ -484,6 +499,7 @@ module.exports = function( } } reconcileChildren(current, workInProgress, value); + memoizeProps(workInProgress, props); return workInProgress.child; } } @@ -503,6 +519,7 @@ module.exports = function( return bailoutOnAlreadyFinishedWork(current, workInProgress); } reconcileChildren(current, workInProgress, nextCoroutine.children); + memoizeProps(workInProgress, nextCoroutine); // This doesn't take arbitrary time so we could synchronously just begin // eagerly do the work of workInProgress.child as an optimization. return workInProgress.child; @@ -537,9 +554,11 @@ module.exports = function( nextChildren, priorityLevel ); + memoizeProps(workInProgress, nextChildren); markChildAsProgressed(current, workInProgress, priorityLevel); } else { reconcileChildren(current, workInProgress, nextChildren); + memoizeProps(workInProgress, nextChildren); } return workInProgress.child; } @@ -606,6 +625,18 @@ module.exports = function( return null; } + function memoizeProps(workInProgress : Fiber, nextProps : any) { + workInProgress.memoizedProps = nextProps; + // Reset the pending props + workInProgress.pendingProps = null; + } + + function memoizeState(workInProgress : Fiber, nextState : any) { + workInProgress.memoizedState = nextState; + // Don't reset the updateQueue, in case there are pending updates. Resetting + // is handled by beginUpdateQueue. + } + function beginWork(current : ?Fiber, workInProgress : Fiber, priorityLevel : PriorityLevel) : ?Fiber { if (workInProgress.pendingWorkPriority === NoWork || workInProgress.pendingWorkPriority > priorityLevel) { @@ -639,9 +670,7 @@ module.exports = function( case HostComponent: return updateHostComponent(current, workInProgress); case HostText: - // Nothing to do here. This is terminal. We'll do the completion step - // immediately after. - return null; + return updateHostText(current, workInProgress); case CoroutineHandlerPhase: // This is a restart. Reset the tag to the initial phase. workInProgress.tag = CoroutineComponent; @@ -683,6 +712,14 @@ module.exports = function( // Unmount the current children as if the component rendered null const nextChildren = null; reconcileChildren(current, workInProgress, nextChildren); + + if (workInProgress.tag === ClassComponent) { + const instance = workInProgress.stateNode; + workInProgress.memoizedProps = instance.props; + workInProgress.memoizedState = instance.state; + workInProgress.pendingProps = null; + } + return workInProgress.child; } diff --git a/src/renderers/shared/fiber/ReactFiberClassComponent.js b/src/renderers/shared/fiber/ReactFiberClassComponent.js index d5eee64cc0147..609662d471b8e 100644 --- a/src/renderers/shared/fiber/ReactFiberClassComponent.js +++ b/src/renderers/shared/fiber/ReactFiberClassComponent.js @@ -15,6 +15,9 @@ import type { Fiber } from 'ReactFiber'; import type { PriorityLevel } from 'ReactPriorityLevel'; +var { + Update, +} = require('ReactTypeOfSideEffect'); var { cacheContext, getMaskedContext, @@ -40,6 +43,8 @@ const isArray = Array.isArray; module.exports = function( scheduleUpdate : (fiber : Fiber, priorityLevel : PriorityLevel) => void, getPriorityContext : () => PriorityLevel, + memoizeProps: (workInProgress : Fiber, props : any) => void, + memoizeState: (workInProgress : Fiber, state : any) => void, ) { // Class component state updater @@ -65,7 +70,7 @@ module.exports = function( }, }; - function checkShouldComponentUpdate(workInProgress, oldProps, newProps, newState, newContext) { + function checkShouldComponentUpdate(workInProgress, oldProps, newProps, oldState, newState, newContext) { if (oldProps === null || (workInProgress.updateQueue && workInProgress.updateQueue.hasForceUpdate)) { // If the workInProgress already has an Update effect, return true return true; @@ -91,7 +96,7 @@ module.exports = function( if (type.prototype && type.prototype.isPureReactComponent) { return ( !shallowEqual(oldProps, newProps) || - !shallowEqual(instance.state, newState) + !shallowEqual(oldState, newState) ); } @@ -198,6 +203,27 @@ module.exports = function( } } + + function markUpdate(workInProgress) { + workInProgress.effectTag |= Update; + } + + function markUpdateIfAlreadyInProgress(current: ?Fiber, workInProgress : Fiber) { + // If an update was already in progress, we should schedule an Update + // effect even though we're bailing out, so that cWU/cDU are called. + if (current) { + if (workInProgress.memoizedProps !== current.memoizedProps || + workInProgress.memoizedState !== current.memoizedState) { + markUpdate(workInProgress); + } + } + } + + function resetInputPointers(workInProgress : Fiber, instance : any) { + instance.props = workInProgress.memoizedProps; + instance.state = workInProgress.memoizedState; + } + function adoptClassInstance(workInProgress : Fiber, instance : any) : void { instance.updater = updater; workInProgress.stateNode = instance; @@ -226,6 +252,7 @@ module.exports = function( // Invokes the mount life-cycles on a previously never rendered instance. function mountClassInstance(workInProgress : Fiber, priorityLevel : PriorityLevel) : void { + markUpdate(workInProgress); const instance = workInProgress.stateNode; const state = instance.state || null; @@ -261,6 +288,10 @@ module.exports = function( // Called on a preexisting class instance. Returns false if a resumed render // could be reused. function resumeMountClassInstance(workInProgress : Fiber, priorityLevel : PriorityLevel) : boolean { + markUpdate(workInProgress); + const instance = workInProgress.stateNode; + resetInputPointers(workInProgress, instance); + let newState = workInProgress.memoizedState; let newProps = workInProgress.pendingProps; if (!newProps) { @@ -282,9 +313,15 @@ module.exports = function( workInProgress, workInProgress.memoizedProps, newProps, + workInProgress.memoizedState, newState, newContext )) { + // Update the existing instance's state, props, and context pointers even + // though we're bailing out. + instance.props = newProps; + instance.state = newState; + instance.context = newContext; return false; } @@ -318,8 +355,9 @@ module.exports = function( // Invokes the update life-cycles and returns false if it shouldn't rerender. function updateClassInstance(current : Fiber, workInProgress : Fiber, priorityLevel : PriorityLevel) : boolean { const instance = workInProgress.stateNode; + resetInputPointers(workInProgress, instance); - const oldProps = workInProgress.memoizedProps || current.memoizedProps; + const oldProps = workInProgress.memoizedProps; let newProps = workInProgress.pendingProps; if (!newProps) { // If there aren't any new props, then we'll reuse the memoized props. @@ -365,28 +403,40 @@ module.exports = function( oldState === newState && !hasContextChanged() && !(updateQueue && updateQueue.hasForceUpdate)) { + markUpdateIfAlreadyInProgress(current, workInProgress); return false; } - if (!checkShouldComponentUpdate( + const shouldUpdate = checkShouldComponentUpdate( workInProgress, oldProps, newProps, + oldState, newState, newContext - )) { - // TODO: Should this get the new props/state updated regardless? - return false; - } + ); - if (typeof instance.componentWillUpdate === 'function') { - instance.componentWillUpdate(newProps, newState, newContext); + if (shouldUpdate) { + markUpdate(workInProgress); + if (typeof instance.componentWillUpdate === 'function') { + instance.componentWillUpdate(newProps, newState, newContext); + } + } else { + markUpdateIfAlreadyInProgress(current, workInProgress); + + // If shouldComponentUpdate returned false, we should still update the + // memoized props/state to indicate that this work can be reused. + memoizeProps(workInProgress, newProps); + memoizeState(workInProgress, newState); } + // Update the existing instance's state, props, and context pointers even + // if shouldComponentUpdate returns false. instance.props = newProps; instance.state = newState; instance.context = newContext; - return true; + + return shouldUpdate; } return { diff --git a/src/renderers/shared/fiber/ReactFiberCompleteWork.js b/src/renderers/shared/fiber/ReactFiberCompleteWork.js index 9a6c036481e78..659d721e41d13 100644 --- a/src/renderers/shared/fiber/ReactFiberCompleteWork.js +++ b/src/renderers/shared/fiber/ReactFiberCompleteWork.js @@ -100,7 +100,7 @@ module.exports = function( } function moveCoroutineToHandlerPhase(current : ?Fiber, workInProgress : Fiber) { - var coroutine = (workInProgress.pendingProps : ?ReactCoroutine); + var coroutine = (workInProgress.memoizedProps : ?ReactCoroutine); if (!coroutine) { throw new Error('Should be resolved by now'); } @@ -170,24 +170,14 @@ module.exports = function( switch (workInProgress.tag) { case FunctionalComponent: - workInProgress.memoizedProps = workInProgress.pendingProps; return null; case ClassComponent: { // We are leaving this subtree, so pop context if any. popContextProvider(workInProgress); - // Don't use the state queue to compute the memoized state. We already - // merged it and assigned it to the instance. Transfer it from there. - // Also need to transfer the props, because pendingProps will be null - // in the case of an update. - const instance = workInProgress.stateNode; - workInProgress.memoizedState = instance.state; - workInProgress.memoizedProps = instance.props; - return null; } case HostRoot: { // TODO: Pop the host container after #8607 lands. - workInProgress.memoizedProps = workInProgress.pendingProps; const fiberRoot = (workInProgress.stateNode : FiberRoot); if (fiberRoot.pendingContext) { fiberRoot.context = fiberRoot.pendingContext; @@ -198,7 +188,7 @@ module.exports = function( case HostComponent: popHostContext(workInProgress); const type = workInProgress.type; - let newProps = workInProgress.pendingProps; + const newProps = workInProgress.memoizedProps; if (current && workInProgress.stateNode != null) { // If we have an alternate, that means this is an update and we need to // schedule a side-effect to do the updates. @@ -207,9 +197,6 @@ module.exports = function( // have newProps so we'll have to reuse them. // TODO: Split the update API as separate for the props vs. children. // Even better would be if children weren't special cased at all tho. - if (!newProps) { - newProps = workInProgress.memoizedProps || oldProps; - } const instance : I = workInProgress.stateNode; const currentHostContext = getHostContext(); if (prepareUpdate(instance, type, oldProps, newProps, currentHostContext)) { @@ -255,20 +242,11 @@ module.exports = function( markUpdate(workInProgress); } } - workInProgress.memoizedProps = newProps; return null; case HostText: - let newText = workInProgress.pendingProps; + let newText = workInProgress.memoizedProps; if (current && workInProgress.stateNode != null) { const oldText = current.memoizedProps; - if (newText === null) { - // If this was a bail out we need to fall back to memoized text. - // This works the same way as HostComponent. - newText = workInProgress.memoizedProps; - if (newText === null) { - newText = oldText; - } - } // If we have an alternate, that means this is an update and we need // to schedule a side-effect to do the updates. if (oldText !== newText) { @@ -288,12 +266,10 @@ module.exports = function( const textInstance = createTextInstance(newText, rootContainerInstance, currentHostContext, workInProgress); workInProgress.stateNode = textInstance; } - workInProgress.memoizedProps = newText; return null; case CoroutineComponent: return moveCoroutineToHandlerPhase(current, workInProgress); case CoroutineHandlerPhase: - workInProgress.memoizedProps = workInProgress.pendingProps; // Reset the tag to now be a first phase coroutine. workInProgress.tag = CoroutineComponent; return null; @@ -301,12 +277,10 @@ module.exports = function( // Does nothing. return null; case Fragment: - workInProgress.memoizedProps = workInProgress.pendingProps; return null; case HostPortal: // TODO: Only mark this as an update if we have any pending callbacks. markUpdate(workInProgress); - workInProgress.memoizedProps = workInProgress.pendingProps; popHostContainer(workInProgress); return null; diff --git a/src/renderers/shared/fiber/ReactFiberScheduler.js b/src/renderers/shared/fiber/ReactFiberScheduler.js index 3df07e503adf2..0447100bccaa1 100644 --- a/src/renderers/shared/fiber/ReactFiberScheduler.js +++ b/src/renderers/shared/fiber/ReactFiberScheduler.js @@ -442,10 +442,6 @@ module.exports = function(config : HostConfig { }); + it('can reuse work that began but did not complete, after being preempted', () => { + let ops = []; + let child; + let sibling; + + function GreatGrandchild() { + ops.push('GreatGrandchild'); + return
; + } + + function Grandchild() { + ops.push('Grandchild'); + return ; + } + + class Child extends React.Component { + state = { step: 0 }; + render() { + child = this; + ops.push('Child'); + return ; + } + } + + class Sibling extends React.Component { + render() { + ops.push('Sibling'); + sibling = this; + return
; + } + } + + function Parent() { + ops.push('Parent'); + return [ + // The extra div is necessary because when Parent bails out during the + // high priority update, its progressedPriority is set to high. + // So its direct children cannot be reused when we resume at + // low priority. I think this would be fixed by changing + // pendingWorkPriority and progressedPriority to be the priority of + // the children only, not including the fiber itself. +
, + , + ]; + } + + ReactNoop.render(); + ReactNoop.flush(); + ops = []; + + // Begin working on a low priority update to Child, but stop before + // GreatGrandchild. Child and Grandchild begin but don't complete. + child.setState({ step: 1 }); + ReactNoop.flushDeferredPri(30); + expect(ops).toEqual([ + 'Child', + 'Grandchild', + ]); + + // Interrupt the current low pri work with a high pri update elsewhere in + // the tree. + ops = []; + ReactNoop.performAnimationWork(() => { + sibling.setState({}); + }); + ReactNoop.flushAnimationPri(); + expect(ops).toEqual(['Sibling']); + + // Continue the low pri work. The work on Child and GrandChild was memoized + // so they should not be worked on again. + ops = []; + ReactNoop.flush(); + expect(ops).toEqual([ + // No Child + // No Grandchild + 'GreatGrandchild', + ]); + }); + it('can reuse work if shouldComponentUpdate is false, after being preempted', () => { var ops = []; @@ -628,6 +707,43 @@ describe('ReactIncremental', () => { }); + it('memoizes work even if shouldComponentUpdate returns false', () => { + let ops = []; + class Foo extends React.Component { + shouldComponentUpdate(nextProps) { + // this.props is the memoized props. So this should return true for + // every update except the first one. + const shouldUpdate = this.props.step !== 1; + ops.push('shouldComponentUpdate: ' + shouldUpdate); + return shouldUpdate; + } + render() { + ops.push('render'); + return
; + } + } + + ReactNoop.render(); + ReactNoop.flush(); + + ops = []; + ReactNoop.render(); + ReactNoop.flush(); + expect(ops).toEqual([ + 'shouldComponentUpdate: false', + ]); + + ops = []; + ReactNoop.render(); + ReactNoop.flush(); + expect(ops).toEqual([ + // If the memoized props were not updated during last bail out, sCU will + // keep returning false. + 'shouldComponentUpdate: true', + 'render', + ]); + }); + it('can update in the middle of a tree using setState', () => { let instance; class Bar extends React.Component { @@ -873,13 +989,13 @@ describe('ReactIncremental', () => { ReactNoop.render(); ReactNoop.flushDeferredPri(50); - // A completed and was reused. B completed but couldn't be reused because - // props differences. C didn't complete and therefore couldn't be reused. - // D never even started so it needed a new instance. - expect(ops).toEqual(['Foo', 'Bar:B2', 'Bar:C', 'Bar:D']); + // A was memoized and reused. B was memoized but couldn't be reused because + // props differences. C was memoized and reused. D never even started so it + // needed a new instance. + expect(ops).toEqual(['Foo', 'Bar:B2', 'Bar:D']); // We expect each rerender to correspond to a new instance. - expect(instances.size).toBe(6); + expect(instances.size).toBe(5); }); it('gets new props when setting state on a partly updated component', () => { @@ -935,7 +1051,7 @@ describe('ReactIncremental', () => { ops = []; ReactNoop.flush(); - expect(ops).toEqual(['Bar:A-1', 'Baz', 'Baz']); + expect(ops).toEqual(['Bar:A-1', 'Baz']); }); it('calls componentWillMount twice if the initial render is aborted', () => { diff --git a/src/renderers/shared/fiber/__tests__/ReactIncrementalSideEffects-test.js b/src/renderers/shared/fiber/__tests__/ReactIncrementalSideEffects-test.js index 15f3d6f5df593..f8b46fa18667d 100644 --- a/src/renderers/shared/fiber/__tests__/ReactIncrementalSideEffects-test.js +++ b/src/renderers/shared/fiber/__tests__/ReactIncrementalSideEffects-test.js @@ -667,7 +667,7 @@ describe('ReactIncrementalSideEffects', () => { ), ]); - expect(ops).toEqual(['Foo', 'Baz', 'Bar']); + expect(ops).toEqual(['Foo']); ops = []; ReactNoop.flush(); @@ -861,7 +861,7 @@ describe('ReactIncrementalSideEffects', () => { ), ]); - expect(ops).toEqual(['Bar', 'Bar']); + expect(ops).toEqual(['Bar']); }); // TODO: Test that side-effects are not cut off when a work in progress node // moves to "current" without flushing due to having lower priority. Does this diff --git a/src/renderers/shared/shared/__tests__/ReactCompositeComponentState-test.js b/src/renderers/shared/shared/__tests__/ReactCompositeComponentState-test.js index d9e884cf89181..bb6fc95baf5dc 100644 --- a/src/renderers/shared/shared/__tests__/ReactCompositeComponentState-test.js +++ b/src/renderers/shared/shared/__tests__/ReactCompositeComponentState-test.js @@ -385,4 +385,32 @@ describe('ReactCompositeComponent-state', () => { 'child render two', ]); }); + + it('should merge state when sCU returns false', function() { + const log = []; + class Test extends React.Component { + state = {a: 0}; + render() { + return null; + } + shouldComponentUpdate(nextProps, nextState) { + log.push( + 'scu from ' + Object.keys(this.state) + + ' to ' + Object.keys(nextState) + ); + return false; + } + } + + const container = document.createElement('div'); + const test = ReactDOM.render(, container); + test.setState({b: 0}); + expect(log.length).toBe(1); + test.setState({c: 0}); + expect(log.length).toBe(2); + expect(log).toEqual([ + 'scu from a to a,b', + 'scu from a,b to a,b,c', + ]); + }); });