diff --git a/packages/react-test-renderer/src/ReactShallowRenderer.js b/packages/react-test-renderer/src/ReactShallowRenderer.js index 50897342d92e5..aba6212c7e0df 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, + destroy: null, + run: true, + }; + } else { + const {memoizedState} = this._workInProgressHook; + this._workInProgressHook.memoizedState = { + isEffectHook, + isLayoutEffect, + create, + inputs, + destroy: memoizedState.destroy, + run: + inputs == null || + !areHookInputsEqual(inputs, memoizedState.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,10 @@ class ReactShallowRenderer { ReactCurrentDispatcher.current = prevDispatcher; } this._finishHooks(element, context); + if (this._options.callEffects) { + this._callEffects(true); + this._callEffects(false); + } } } } @@ -690,6 +752,27 @@ class ReactShallowRenderer { // because DOM refs are not available. } + _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.destroy) { + memoizedState.destroy(); + } + memoizedState.destroy = memoizedState.create(); + } + } + } + _updateClassComponent( elementType: Function, element: ReactElement, diff --git a/packages/react-test-renderer/src/__tests__/ReactShallowRendererHooks-test.js b/packages/react-test-renderer/src/__tests__/ReactShallowRendererHooks-test.js index 6b8a962ad5b14..f5a7f3d5253e7 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,111 @@ 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('create effect'); + }); + + React.useLayoutEffect(() => { + happenings.push('create 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', + 'create layout effect', + 'create effect', + ]); + }); + + it('should trigger effects and destroy depending on inputs', () => { + let _setFriend; + const happenings = []; + + function SomeComponent() { + const [friend, setFriend] = React.useState('Bons'); + const cat = 'Muskus'; + _setFriend = setFriend; + + React.useEffect( + () => { + happenings.push('create friend effect'); + return () => { + happenings.push('destroy friend effect'); + }; + }, + [friend], + ); + + React.useEffect(() => { + happenings.push('create empty effect'); + return () => { + happenings.push('destroy empty effect'); + }; + }); + + React.useEffect( + () => { + happenings.push('create cat effect'); + return () => { + happenings.push('destroy cat effect'); + }; + }, + [cat], + ); + + React.useEffect( + () => { + happenings.push('create both effect'); + return () => { + happenings.push('destroy both effect'); + }; + }, + [friend, cat], + ); + + return ( +
+ Hello {friend} with {cat} +
+ ); + } + + const shallowRenderer = createRenderer({callEffects: true}); + shallowRenderer.render(); + + expect(happenings).toEqual([ + 'create friend effect', + 'create empty effect', + 'create cat effect', + 'create both effect', + ]); + + happenings.splice(0); + _setFriend('Maryam'); + expect(happenings).toEqual([ + 'destroy friend effect', + 'create friend effect', + 'destroy empty effect', + 'create empty effect', + 'destroy both effect', + 'create both effect', + ]); + }); + }); + it('should work with useRef', () => { function SomeComponent() { const randomNumberRef = React.useRef({number: Math.random()});