From 031519d697326a01988d344953ce050b3b5fdfab Mon Sep 17 00:00:00 2001 From: James Pike Date: Sun, 21 Jul 2019 23:34:15 +0800 Subject: [PATCH 1/3] [Shallow] Implement callEffects option (#15275) --- .../src/ReactShallowRenderer.js | 110 +++++++++++++++++- .../ReactShallowRendererHooks-test.js | 34 +++++- 2 files changed, 137 insertions(+), 7 deletions(-) diff --git a/packages/react-test-renderer/src/ReactShallowRenderer.js b/packages/react-test-renderer/src/ReactShallowRenderer.js index 50897342d92e5..d021159e953bf 100644 --- a/packages/react-test-renderer/src/ReactShallowRenderer.js +++ b/packages/react-test-renderer/src/ReactShallowRenderer.js @@ -175,12 +175,21 @@ function basicStateReducer(state: S, action: BasicStateAction): S { return typeof action === 'function' ? action(state) : action; } +type ShallowRenderOptions = { + callEffects: boolean, +}; + +const isEffectHook = Symbol(); + class ReactShallowRenderer { - static createRenderer = function() { - return new ReactShallowRenderer(); + static createRenderer = function( + options: ShallowRenderOptions = {callEffects: false}, + ) { + return new ReactShallowRenderer(options); }; - constructor() { + constructor(options: ShallowRenderOptions) { + this._options = options; this._reset(); } @@ -202,6 +211,7 @@ class ReactShallowRenderer { this._numberOfReRenders = 0; } + _options: ShallowRenderOptions; _context: null | Object; _newState: null | Object; _instance: any; @@ -311,6 +321,54 @@ class ReactShallowRenderer { ); }; + const useGenericEffect = ( + create: () => (() => void) | void, + inputs: Array | void | null, + isLayoutEffect: boolean, + ) => { + this._validateCurrentlyRenderingComponent(); + this._createWorkInProgressHook(); + + if (this._workInProgressHook !== null) { + if (this._workInProgressHook.memoizedState == null) { + this._workInProgressHook.memoizedState = { + isEffectHook, + isLayoutEffect, + create, + inputs, + cleanup: null, + run: true, + }; + } else { + const {memoizedState} = this._workInProgressHook; + this._workInProgressHook.memoizedState = { + isEffectHook, + isLayoutEffect, + create, + inputs, + cleanup: memoizedState.cleanup, + run: + inputs == null || + shouldRunEffectsBasedOnInputs(memoizedState.inputs, inputs), + }; + } + } + }; + + const useEffect = ( + create: () => (() => void) | void, + inputs: Array | void | null, + ) => { + useGenericEffect(create, inputs, false); + }; + + const useLayoutEffect = ( + create: () => (() => void) | void, + inputs: Array | void | null, + ) => { + useGenericEffect(create, inputs, true); + }; + const useMemo = ( nextCreate: () => T, deps: Array | void | null, @@ -385,9 +443,9 @@ class ReactShallowRenderer { return readContext(context); }, useDebugValue: noOp, - useEffect: noOp, + useEffect: this._options.callEffects ? useEffect : noOp, useImperativeHandle: noOp, - useLayoutEffect: noOp, + useLayoutEffect: this._options.callEffects ? useLayoutEffect : noOp, useMemo, useReducer, useRef, @@ -630,6 +688,7 @@ class ReactShallowRenderer { ReactCurrentDispatcher.current = prevDispatcher; } this._finishHooks(element, context); + this._callEffectsIfDesired(); } } } @@ -690,6 +749,36 @@ class ReactShallowRenderer { // because DOM refs are not available. } + _callEffectsIfDesired() { + if (!this._options.callEffects) { + return; + } + + this._callEffects(true); + this._callEffects(false); + } + + _callEffects(callLayoutEffects: boolean) { + for ( + let hook = this._firstWorkInProgressHook; + hook !== null; + hook = hook.next + ) { + const {memoizedState} = hook; + if ( + memoizedState != null && + memoizedState.isEffectHook === isEffectHook && + memoizedState.isLayoutEffect === callLayoutEffects && + memoizedState.run + ) { + if (memoizedState.cleanup) { + memoizedState.cleanup(); + } + memoizedState.cleanup = memoizedState.create(); + } + } + } + _updateClassComponent( elementType: Function, element: ReactElement, @@ -833,4 +922,15 @@ function getMaskedContext(contextTypes, unmaskedContext) { return context; } +function shouldRunEffectsBasedOnInputs( + before: Array | void | null, + after: Array, +) { + if (before == null || before.length !== after.length) { + return true; + } + + return before.some((value, i) => after[i] !== value); +} + export default ReactShallowRenderer; diff --git a/packages/react-test-renderer/src/__tests__/ReactShallowRendererHooks-test.js b/packages/react-test-renderer/src/__tests__/ReactShallowRendererHooks-test.js index 6b8a962ad5b14..c4f7da88182cb 100644 --- a/packages/react-test-renderer/src/__tests__/ReactShallowRendererHooks-test.js +++ b/packages/react-test-renderer/src/__tests__/ReactShallowRendererHooks-test.js @@ -231,8 +231,8 @@ describe('ReactShallowRenderer with hooks', () => { ); }); - it('should not trigger effects', () => { - let effectsCalled = []; + it('should not trigger effects by default', () => { + const effectsCalled = []; function SomeComponent({defaultName}) { React.useEffect(() => { @@ -252,6 +252,36 @@ describe('ReactShallowRenderer with hooks', () => { expect(effectsCalled).toEqual([]); }); + describe('when callEffects option is used', () => { + it('should trigger effects after render', () => { + const happenings = []; + + function SomeComponent({defaultName}) { + React.useEffect(() => { + happenings.push('call effect'); + }); + + React.useLayoutEffect(() => { + happenings.push('call layout effect'); + }); + + happenings.push('render'); + + return
Hello world
; + } + + const shallowRenderer = createRenderer({callEffects: true}); + shallowRenderer.render(); + + // Note the layout effect is triggered first. + expect(happenings).toEqual([ + 'render', + 'call layout effect', + 'call effect', + ]); + }); + }); + it('should work with useRef', () => { function SomeComponent() { const randomNumberRef = React.useRef({number: Math.random()}); From 3b648a15f445b07a1b207ad889b86d0afa8bbea3 Mon Sep 17 00:00:00 2001 From: James Pike Date: Thu, 25 Jul 2019 09:23:02 +0100 Subject: [PATCH 2/3] [Shallow] Add tests for effects with inputs and cleanup (#15275) --- .../src/ReactShallowRenderer.js | 27 ++----- .../ReactShallowRendererHooks-test.js | 75 +++++++++++++++++++ 2 files changed, 80 insertions(+), 22 deletions(-) diff --git a/packages/react-test-renderer/src/ReactShallowRenderer.js b/packages/react-test-renderer/src/ReactShallowRenderer.js index d021159e953bf..5f721f596bed7 100644 --- a/packages/react-test-renderer/src/ReactShallowRenderer.js +++ b/packages/react-test-renderer/src/ReactShallowRenderer.js @@ -349,7 +349,7 @@ class ReactShallowRenderer { cleanup: memoizedState.cleanup, run: inputs == null || - shouldRunEffectsBasedOnInputs(memoizedState.inputs, inputs), + !areHookInputsEqual(inputs, memoizedState.inputs), }; } } @@ -688,7 +688,10 @@ class ReactShallowRenderer { ReactCurrentDispatcher.current = prevDispatcher; } this._finishHooks(element, context); - this._callEffectsIfDesired(); + if (this._options.callEffects) { + this._callEffects(true); + this._callEffects(false); + } } } } @@ -749,15 +752,6 @@ class ReactShallowRenderer { // because DOM refs are not available. } - _callEffectsIfDesired() { - if (!this._options.callEffects) { - return; - } - - this._callEffects(true); - this._callEffects(false); - } - _callEffects(callLayoutEffects: boolean) { for ( let hook = this._firstWorkInProgressHook; @@ -922,15 +916,4 @@ function getMaskedContext(contextTypes, unmaskedContext) { return context; } -function shouldRunEffectsBasedOnInputs( - before: Array | void | null, - after: Array, -) { - if (before == null || before.length !== after.length) { - return true; - } - - return before.some((value, i) => after[i] !== value); -} - export default ReactShallowRenderer; diff --git a/packages/react-test-renderer/src/__tests__/ReactShallowRendererHooks-test.js b/packages/react-test-renderer/src/__tests__/ReactShallowRendererHooks-test.js index c4f7da88182cb..455b1cbe3294d 100644 --- a/packages/react-test-renderer/src/__tests__/ReactShallowRendererHooks-test.js +++ b/packages/react-test-renderer/src/__tests__/ReactShallowRendererHooks-test.js @@ -280,6 +280,81 @@ describe('ReactShallowRenderer with hooks', () => { 'call effect', ]); }); + + it('should trigger effects and cleanup depending on inputs', () => { + let _setFriend; + const happenings = []; + + function SomeComponent() { + const [friend, setFriend] = React.useState('Bons'); + const [cat] = React.useState('Muskus'); + _setFriend = setFriend; + + React.useEffect( + () => { + happenings.push('call friend effect'); + return () => { + happenings.push('cleanup friend effect'); + }; + }, + [friend], + ); + + React.useEffect(() => { + happenings.push('call empty effect'); + return () => { + happenings.push('cleanup empty effect'); + }; + }); + + React.useEffect( + () => { + happenings.push('call cat effect'); + return () => { + happenings.push('cleanup cat effect'); + }; + }, + [cat], + ); + + React.useEffect( + () => { + happenings.push('call both effect'); + return () => { + happenings.push('cleanup both effect'); + }; + }, + [friend, cat], + ); + + return ( +
+ Hello {friend} with {cat} +
+ ); + } + + const shallowRenderer = createRenderer({callEffects: true}); + shallowRenderer.render(); + + expect(happenings).toEqual([ + 'call friend effect', + 'call empty effect', + 'call cat effect', + 'call both effect', + ]); + + happenings.splice(0); + _setFriend('Maryam'); + expect(happenings).toEqual([ + 'cleanup friend effect', + 'call friend effect', + 'cleanup empty effect', + 'call empty effect', + 'cleanup both effect', + 'call both effect', + ]); + }); }); it('should work with useRef', () => { From 9e6ad0ac077fc7f72a4ec17ade583c5826b054bc Mon Sep 17 00:00:00 2001 From: James Pike Date: Fri, 26 Jul 2019 10:44:18 +0100 Subject: [PATCH 3/3] [Shallow] Rename effect functions to match rest of react (#15275) --- .../src/ReactShallowRenderer.js | 10 ++-- .../ReactShallowRendererHooks-test.js | 48 +++++++++---------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/react-test-renderer/src/ReactShallowRenderer.js b/packages/react-test-renderer/src/ReactShallowRenderer.js index 5f721f596bed7..aba6212c7e0df 100644 --- a/packages/react-test-renderer/src/ReactShallowRenderer.js +++ b/packages/react-test-renderer/src/ReactShallowRenderer.js @@ -336,7 +336,7 @@ class ReactShallowRenderer { isLayoutEffect, create, inputs, - cleanup: null, + destroy: null, run: true, }; } else { @@ -346,7 +346,7 @@ class ReactShallowRenderer { isLayoutEffect, create, inputs, - cleanup: memoizedState.cleanup, + destroy: memoizedState.destroy, run: inputs == null || !areHookInputsEqual(inputs, memoizedState.inputs), @@ -765,10 +765,10 @@ class ReactShallowRenderer { memoizedState.isLayoutEffect === callLayoutEffects && memoizedState.run ) { - if (memoizedState.cleanup) { - memoizedState.cleanup(); + if (memoizedState.destroy) { + memoizedState.destroy(); } - memoizedState.cleanup = memoizedState.create(); + memoizedState.destroy = memoizedState.create(); } } } diff --git a/packages/react-test-renderer/src/__tests__/ReactShallowRendererHooks-test.js b/packages/react-test-renderer/src/__tests__/ReactShallowRendererHooks-test.js index 455b1cbe3294d..f5a7f3d5253e7 100644 --- a/packages/react-test-renderer/src/__tests__/ReactShallowRendererHooks-test.js +++ b/packages/react-test-renderer/src/__tests__/ReactShallowRendererHooks-test.js @@ -258,11 +258,11 @@ describe('ReactShallowRenderer with hooks', () => { function SomeComponent({defaultName}) { React.useEffect(() => { - happenings.push('call effect'); + happenings.push('create effect'); }); React.useLayoutEffect(() => { - happenings.push('call layout effect'); + happenings.push('create layout effect'); }); happenings.push('render'); @@ -276,42 +276,42 @@ describe('ReactShallowRenderer with hooks', () => { // Note the layout effect is triggered first. expect(happenings).toEqual([ 'render', - 'call layout effect', - 'call effect', + 'create layout effect', + 'create effect', ]); }); - it('should trigger effects and cleanup depending on inputs', () => { + it('should trigger effects and destroy depending on inputs', () => { let _setFriend; const happenings = []; function SomeComponent() { const [friend, setFriend] = React.useState('Bons'); - const [cat] = React.useState('Muskus'); + const cat = 'Muskus'; _setFriend = setFriend; React.useEffect( () => { - happenings.push('call friend effect'); + happenings.push('create friend effect'); return () => { - happenings.push('cleanup friend effect'); + happenings.push('destroy friend effect'); }; }, [friend], ); React.useEffect(() => { - happenings.push('call empty effect'); + happenings.push('create empty effect'); return () => { - happenings.push('cleanup empty effect'); + happenings.push('destroy empty effect'); }; }); React.useEffect( () => { - happenings.push('call cat effect'); + happenings.push('create cat effect'); return () => { - happenings.push('cleanup cat effect'); + happenings.push('destroy cat effect'); }; }, [cat], @@ -319,9 +319,9 @@ describe('ReactShallowRenderer with hooks', () => { React.useEffect( () => { - happenings.push('call both effect'); + happenings.push('create both effect'); return () => { - happenings.push('cleanup both effect'); + happenings.push('destroy both effect'); }; }, [friend, cat], @@ -338,21 +338,21 @@ describe('ReactShallowRenderer with hooks', () => { shallowRenderer.render(); expect(happenings).toEqual([ - 'call friend effect', - 'call empty effect', - 'call cat effect', - 'call both effect', + 'create friend effect', + 'create empty effect', + 'create cat effect', + 'create both effect', ]); happenings.splice(0); _setFriend('Maryam'); expect(happenings).toEqual([ - 'cleanup friend effect', - 'call friend effect', - 'cleanup empty effect', - 'call empty effect', - 'cleanup both effect', - 'call both effect', + 'destroy friend effect', + 'create friend effect', + 'destroy empty effect', + 'create empty effect', + 'destroy both effect', + 'create both effect', ]); }); });