diff --git a/.circleci/config.yml b/.circleci/config.yml index 21f4423..8eea65c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -95,8 +95,6 @@ workflows: requires: - check filters: - branches: - only: master tags: only: /.*/ - publish-doc: diff --git a/packages/component-test-utils-react/src/dispatcher/index.js b/packages/component-test-utils-react/src/dispatcher/index.js index c9b2aac..4d02b08 100644 --- a/packages/component-test-utils-react/src/dispatcher/index.js +++ b/packages/component-test-utils-react/src/dispatcher/index.js @@ -2,13 +2,7 @@ // https://github.com/facebook/react/blob/master/packages/react/src/ReactHooks.js class Dispatcher { - /* ReadContext: () => {}, - useCallback: () => {}, - useDebugValue: () => {}, - useImperativeHandle: () => {}, - useLayoutEffect: () => {}, - useMemo: () => {}, - useRef: () => {}, */ + /* ReadContext: () => {} */ constructor(shallowedComponent) { this._hookStorage = []; @@ -34,24 +28,29 @@ class Dispatcher { return hookIndex; } + _isSameMemo(memo, hookIndex) { + const haveMemo = + this._hookStorage[hookIndex] && this._hookStorage[hookIndex].memo; + + return ( + haveMemo && + (this._hookStorage[hookIndex].memo === memo || + !this._hookStorage[hookIndex].memo.find( + (runningMemo, i) => runningMemo !== memo[i] + )) + ); + } + useState(initialState) { return this.useReducer((_, arg) => arg, initialState); } useEffect(fn, memo) { const hookIndex = this._getHookIndex(); - - const haveMemo = this._hookStorage[hookIndex]; - // If effect have no memo, consider memo have changed - const haveSameMemo = - haveMemo && - (this._hookStorage[hookIndex] === memo || - !this._hookStorage[hookIndex].find( - (runningMemo, i) => runningMemo !== memo[i] - )); + const haveSameMemo = this._isSameMemo(memo, hookIndex); if (!haveSameMemo) { - this._hookStorage[hookIndex] = memo; + this._hookStorage[hookIndex] = {memo}; fn(); } } @@ -81,6 +80,61 @@ class Dispatcher { } ]; } + + useCallback(fn, memo) { + return this.useMemo(() => fn, memo); + } + + useMemo(fn, memo) { + const hookIndex = this._getHookIndex(); + const haveSameMemo = this._isSameMemo(memo, hookIndex); + + if (haveSameMemo) { + return this._hookStorage[hookIndex].value; + } + + const value = fn(); + this._hookStorage[hookIndex] = {memo, value}; + return value; + } + + useRef(initialValue) { + const hookIndex = this._getHookIndex(); + + if (!this._hookStorage[hookIndex]) { + this._hookStorage[hookIndex] = { + current: initialValue + }; + } + + return this._hookStorage[hookIndex]; + } + + useImperativeHandle(ref, objectBuilder, memo) { + const hookIndex = this._getHookIndex(); + const haveSameMemo = this._isSameMemo(memo, hookIndex); + + if (haveSameMemo) { + return; + } + + this._hookStorage[hookIndex] = {memo}; + + ref.current = { + ...ref.current, + ...objectBuilder() + }; + } + + useLayoutEffect(...args) { + return this.useEffect(...args); + } + + useDebugValue(...args) { + if (this.debug) { + console.debug(...args); + } + } } exports.createDispatcher = shallowedComponent => { diff --git a/packages/component-test-utils-react/src/dispatcher/index.spec.js b/packages/component-test-utils-react/src/dispatcher/index.spec.js index 7c84605..85325b1 100644 --- a/packages/component-test-utils-react/src/dispatcher/index.spec.js +++ b/packages/component-test-utils-react/src/dispatcher/index.spec.js @@ -78,6 +78,7 @@ describe('dispatcher', () => { expect(contextValue).toBe(42); }); }); + describe('useReducer', () => { it('should return the initialState at the first render', () => { const initialState = {count: 0}; @@ -122,28 +123,174 @@ describe('dispatcher', () => { expect(state2.count).toBe(45); }); }); -}); -/* Const initialState = {count: 0}; - -function reducer(state, action) { - switch (action.type) { - case 'increment': - return {count: state.count + 1}; - case 'decrement': - return {count: state.count - 1}; - default: - throw new Error(); - } -} - -function Counter({initialState}) { - const [state, dispatch] = useReducer(reducer, initialState); - return ( - <> - Total : {state.count} - - - - ); -} */ + describe('useCallback', () => { + it('should generate the callback each time when no memo is given', () => { + const countFn = jest.fn(); + + const memoizedCallback1 = dispatcher.useCallback( + jest.fn(() => countFn()) + ); + + dispatcher._informDipatcherRenderIsComming(); + const memoizedCallback2 = dispatcher.useCallback( + jest.fn(() => countFn()) + ); + + expect(memoizedCallback1).not.toBe(memoizedCallback2); + }); + + it('should generate the callback each time when memo given not the same', () => { + const countFn = jest.fn(); + + const memoizedCallback1 = dispatcher.useCallback( + jest.fn(() => countFn()), + [1] + ); + + dispatcher._informDipatcherRenderIsDone(); + const memoizedCallback2 = dispatcher.useCallback( + jest.fn(() => countFn()), + [2] + ); + + expect(memoizedCallback1).not.toBe(memoizedCallback2); + }); + + it('should not generate the callback each time when memo is given', () => { + const countFn = jest.fn(); + + const memoizedCallback1 = dispatcher.useCallback( + jest.fn(() => countFn()), + [1] + ); + + dispatcher._informDipatcherRenderIsDone(); + const memoizedCallback2 = dispatcher.useCallback( + jest.fn(() => countFn()), + [1] + ); + + expect(memoizedCallback1).toBe(memoizedCallback2); + }); + }); + + describe('useMemo', () => { + let computeExpensiveValue; + beforeEach(() => { + let i = 0; + computeExpensiveValue = () => { + i++; + return i; + }; + }); + + it('should computeValue each time when no memo is send', () => { + dispatcher.useMemo(() => computeExpensiveValue()); + dispatcher._informDipatcherRenderIsDone(); + const computedNTime = dispatcher.useMemo(() => computeExpensiveValue()); + + expect(computedNTime).toBe(2); + }); + + it('should computeValue each time when different memo is send', () => { + dispatcher.useMemo(() => computeExpensiveValue(), [1]); + dispatcher._informDipatcherRenderIsDone(); + const computedNTime = dispatcher.useMemo(() => computeExpensiveValue(), [ + 2 + ]); + + expect(computedNTime).toBe(2); + }); + + it('should computeValue one time when same memo is send', () => { + dispatcher.useMemo(() => computeExpensiveValue(), [1]); + dispatcher._informDipatcherRenderIsDone(); + const computedNTime = dispatcher.useMemo(() => computeExpensiveValue(), [ + 1 + ]); + + expect(computedNTime).toBe(1); + }); + }); + + describe('useRef', () => { + it('should return the initial value into the current', () => { + const result = dispatcher.useRef(42); + expect(result.current).toEqual(42); + }); + + it('should return the same object each render', () => { + const object = {}; + dispatcher.useRef(object); + object.focus = true; + dispatcher._informDipatcherRenderIsDone(); + const result = dispatcher.useRef(object); + + expect(result.current).toBe(object); + }); + }); + + describe('useImperativeHandle', () => { + it('should enhance the reference given in params', () => { + const ref = dispatcher.useRef({}); + const focusFn = jest.fn(); + dispatcher.useImperativeHandle(ref, () => ({ + focus: focusFn + })); + + ref.current.focus(); + + expect(focusFn).toHaveBeenCalled(); + }); + + it('should memoize the function given', () => { + const objectBuilder = jest.fn(() => ({ + focus: jest.fn() + })); + + const ref = dispatcher.useRef({}); + dispatcher.useImperativeHandle(ref, objectBuilder, [1]); + dispatcher._informDipatcherRenderIsDone(); + + dispatcher.useRef({}); + dispatcher.useImperativeHandle(ref, objectBuilder, [1]); + dispatcher._informDipatcherRenderIsDone(); + + expect(objectBuilder).toHaveBeenCalledTimes(1); + + dispatcher.useRef({}); + dispatcher.useImperativeHandle(ref, objectBuilder, [2]); + + expect(objectBuilder).toHaveBeenCalledTimes(2); + }); + }); + + describe('useLayoutEffect', () => { + it('should call useEffect', () => { + dispatcher.useEffect = jest.fn(() => 4); + + const args = [jest.fn(), [1, 4]]; + + const res = dispatcher.useLayoutEffect(...args); + + expect(dispatcher.useEffect).toHaveBeenCalledWith(...args); + expect(res).toBe(4); + }); + }); + + describe('useDebugValue', () => { + it('should do nothing when dispatcher configuration is not debug', () => { + console.debug = jest.fn(); + dispatcher.useDebugValue('yolo'); + expect(console.debug).not.toHaveBeenCalled(); + }); + + it('should call console.debug when dispatcher configuration is debug', () => { + dispatcher.debug = true; + console.debug = jest.fn(); + dispatcher.useDebugValue('yolo'); + expect(console.debug).toHaveBeenCalledWith('yolo'); + }); + }); +}); diff --git a/packages/component-test-utils-react/src/render/render.js b/packages/component-test-utils-react/src/render/render.js index 4230e30..11359c2 100644 --- a/packages/component-test-utils-react/src/render/render.js +++ b/packages/component-test-utils-react/src/render/render.js @@ -6,16 +6,28 @@ const render = (reactEl, config, ShallowRender) => { return reactEl; } - // When rendering modify the context value - if (ReactIs.isContextProvider(reactEl) && reactEl.props.value) { - reactEl.type._context._currentValue = reactEl.props.value; - } - const isAlreadyMocked = Boolean(reactEl._mock); if (isAlreadyMocked) { reactEl._mock._render(); } + if (!isAlreadyMocked && ReactIs.isForwardRef(reactEl)) { + const shallowRender = new ShallowRender( + reactEl, + config + ); + + return { + ...shallowRender._rendered, + _mock: shallowRender + }; + } + + // When rendering modify the context value + if (ReactIs.isContextProvider(reactEl) && reactEl.props.value) { + reactEl.type._context._currentValue = reactEl.props.value; + } + const shouldBeMocked = typeof reactEl.type === 'function' && Object.keys(config.mocks).includes(reactEl.type.name); diff --git a/packages/component-test-utils-react/src/shallow.js b/packages/component-test-utils-react/src/shallow.js index f185030..9bf27b3 100644 --- a/packages/component-test-utils-react/src/shallow.js +++ b/packages/component-test-utils-react/src/shallow.js @@ -1,5 +1,5 @@ const React = require('react'); -// Const ReactIs = require('react-is'); +const ReactIs = require('react-is'); const {getHtml} = require('./methods/html'); const {render} = require('./render/render'); const {createDispatcher} = require('./dispatcher/'); @@ -23,26 +23,33 @@ class ShallowRender { this._render(); } - _render(props) { + _render(customProps) { const prevDispatcher = ReactCurrentDispatcher.current; ReactCurrentDispatcher.current = this._dispatcher; + this._dispatcher.debug = Boolean(this._config.debug); + this._dispatcher._informDipatcherRenderIsComming(); let reactEl; + const props = customProps || this._component.props; + if (isClassComponent(this._component.type)) { const instance = new this._component.type( // eslint-disable-line new-cap - props || this._component.props + props // This._context, // this._updater, ); reactEl = instance.render(); - } else { - reactEl = this._component.type.call( + } else if (ReactIs.isForwardRef(this._component)) { + reactEl = this._component.type.render.call( undefined, - props || this._component.props + props, + this._component.ref ); + } else { + reactEl = this._component.type.call(undefined, props); } this._rendered = render(reactEl, this._config, ShallowRender); diff --git a/packages/component-test-utils-react/src/shallow.spec.js b/packages/component-test-utils-react/src/shallow.spec.js index e87f538..a1704aa 100644 --- a/packages/component-test-utils-react/src/shallow.spec.js +++ b/packages/component-test-utils-react/src/shallow.spec.js @@ -214,7 +214,11 @@ describe('react shallow render', () => { } } - const OuterComponent = () =>
; + const OuterComponent = () => ( +
+ +
+ ); const cmp = shallow(); @@ -503,4 +507,80 @@ describe('react shallow render', () => { ); }); }); + + describe('ref', () => { + it('should not ignore fowardref HOC', () => { + const FancyButton = React.forwardRef((props, ref) => ( + + )); + + const cmp = shallow(test); + + expect(cmp.html()).toBe( + '' + ); + }); + + it('should work with foward ref', () => { + const ref = React.createRef(); + + const FancyButton = React.forwardRef((props, ref) => { + return ( + + ); + }); + + const App = () => { + return Click me!; + }; + + const cmp = shallow(, { + mocks: {FancyButton} + }); + + expect(cmp.html()).toBe( + '' + ); + expect(ref).toBe(cmp._rendered.ref); + }); + }); + + describe('debug option', () => { + beforeEach(() => { + console.initialDebug = console.debug; + console.debug = jest.fn(); + }); + afterEach(() => { + console.debug = console.initialDebug; + delete console.initialDebug; + }); + + it('should call console.debug', () => { + const Component = () => { + React.useDebugValue('need to be logged'); + return
; + }; + + shallow(, { + debug: true + }); + + expect(console.debug).toHaveBeenCalled(); + }); + + it('should not call console.debug by default', () => { + const Component = () => { + React.useDebugValue('need to be logged'); + return
; + }; + + shallow(); + + expect(console.debug).not.toHaveBeenCalled(); + }); + }); }); diff --git a/website/docs/api-react.md b/website/docs/api-react.md index 4ee9b43..233b92a 100644 --- a/website/docs/api-react.md +++ b/website/docs/api-react.md @@ -1 +1,25 @@ -TODO react DOC +## React specialty + +### useDebugValue() hook + +If you want to test that your component use this hook, you can set the debug option to true. + +If the option is at true, useDebugValue will call `console.debug` function. + +#### example with jest : + +```jsx +it('should call console.debug', () => { + console.debug = jest.fn(); + const Component = () => { + React.useDebugValue('need to be logged'); + return
; + }; + + shallow(, { + debug: true + }); + + expect(console.debug).toHaveBeenCalled(); +}); +``` diff --git a/website/docs/getting-started.md b/website/docs/getting-started.md index 6acbdfe..5e8cc5d 100644 --- a/website/docs/getting-started.md +++ b/website/docs/getting-started.md @@ -4,7 +4,7 @@ title: Getting Started ## React -Dtart by installing dependencies: +Start by installing dependencies: `npm i -D component-test-utils-react` with npm, or `yarn add -D component-test-utils-react` with yarn. Then, you can test everything work well by executing this test (with jest) :