diff options
Diffstat (limited to 'preact/hooks/test/browser')
-rw-r--r-- | preact/hooks/test/browser/combinations.test.js | 301 | ||||
-rw-r--r-- | preact/hooks/test/browser/errorBoundary.test.js | 92 | ||||
-rw-r--r-- | preact/hooks/test/browser/hooks.options.test.js | 154 | ||||
-rw-r--r-- | preact/hooks/test/browser/useCallback.test.js | 41 | ||||
-rw-r--r-- | preact/hooks/test/browser/useContext.test.js | 351 | ||||
-rw-r--r-- | preact/hooks/test/browser/useDebugValue.test.js | 71 | ||||
-rw-r--r-- | preact/hooks/test/browser/useEffect.test.js | 373 | ||||
-rw-r--r-- | preact/hooks/test/browser/useEffectAssertions.test.js | 142 | ||||
-rw-r--r-- | preact/hooks/test/browser/useImperativeHandle.test.js | 182 | ||||
-rw-r--r-- | preact/hooks/test/browser/useLayoutEffect.test.js | 326 | ||||
-rw-r--r-- | preact/hooks/test/browser/useMemo.test.js | 125 | ||||
-rw-r--r-- | preact/hooks/test/browser/useReducer.test.js | 214 | ||||
-rw-r--r-- | preact/hooks/test/browser/useRef.test.js | 50 | ||||
-rw-r--r-- | preact/hooks/test/browser/useState.test.js | 214 |
14 files changed, 2636 insertions, 0 deletions
diff --git a/preact/hooks/test/browser/combinations.test.js b/preact/hooks/test/browser/combinations.test.js new file mode 100644 index 0000000..986533c --- /dev/null +++ b/preact/hooks/test/browser/combinations.test.js @@ -0,0 +1,301 @@ +import { setupRerender, act } from 'preact/test-utils'; +import { createElement, render, Component } from 'preact'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import { + useState, + useReducer, + useEffect, + useLayoutEffect, + useRef +} from 'preact/hooks'; +import { scheduleEffectAssert } from '../_util/useEffectUtil'; + +/** @jsx createElement */ + +describe('combinations', () => { + /** @type {HTMLDivElement} */ + let scratch; + + /** @type {() => void} */ + let rerender; + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('can mix useState hooks', () => { + const states = {}; + const setStates = {}; + + function Parent() { + const [state1, setState1] = useState(1); + const [state2, setState2] = useState(2); + + Object.assign(states, { state1, state2 }); + Object.assign(setStates, { setState1, setState2 }); + + return <Child />; + } + + function Child() { + const [state3, setState3] = useState(3); + const [state4, setState4] = useState(4); + + Object.assign(states, { state3, state4 }); + Object.assign(setStates, { setState3, setState4 }); + + return null; + } + + render(<Parent />, scratch); + expect(states).to.deep.equal({ + state1: 1, + state2: 2, + state3: 3, + state4: 4 + }); + + setStates.setState2(n => n * 10); + setStates.setState3(n => n * 10); + rerender(); + expect(states).to.deep.equal({ + state1: 1, + state2: 20, + state3: 30, + state4: 4 + }); + }); + + it('can rerender asynchronously from within an effect', () => { + const didRender = sinon.spy(); + + function Comp() { + const [counter, setCounter] = useState(0); + + useEffect(() => { + if (counter === 0) setCounter(1); + }); + + didRender(counter); + return null; + } + + render(<Comp />, scratch); + + return scheduleEffectAssert(() => { + rerender(); + expect(didRender).to.have.been.calledTwice.and.calledWith(1); + }); + }); + + it('can rerender synchronously from within a layout effect', () => { + const didRender = sinon.spy(); + + function Comp() { + const [counter, setCounter] = useState(0); + + useLayoutEffect(() => { + if (counter === 0) setCounter(1); + }); + + didRender(counter); + return null; + } + + render(<Comp />, scratch); + rerender(); + + expect(didRender).to.have.been.calledTwice.and.calledWith(1); + }); + + it('can access refs from within a layout effect callback', () => { + let refAtLayoutTime; + + function Comp() { + const input = useRef(); + + useLayoutEffect(() => { + refAtLayoutTime = input.current; + }); + + return <input ref={input} value="hello" />; + } + + render(<Comp />, scratch); + + expect(refAtLayoutTime.value).to.equal('hello'); + }); + + it('can use multiple useState and useReducer hooks', () => { + let states = []; + let dispatchState4; + + function reducer1(state, action) { + switch (action.type) { + case 'increment': + return state + action.count; + } + } + + function reducer2(state, action) { + switch (action.type) { + case 'increment': + return state + action.count * 2; + } + } + + function Comp() { + const [state1] = useState(0); + const [state2] = useReducer(reducer1, 10); + const [state3] = useState(1); + const [state4, dispatch] = useReducer(reducer2, 20); + + dispatchState4 = dispatch; + states.push(state1, state2, state3, state4); + + return null; + } + + render(<Comp />, scratch); + + expect(states).to.deep.equal([0, 10, 1, 20]); + + states = []; + + dispatchState4({ type: 'increment', count: 10 }); + rerender(); + + expect(states).to.deep.equal([0, 10, 1, 40]); + }); + + it('ensures useEffect always schedule after the next paint following a redraw effect, when using the default debounce strategy', () => { + let effectCount = 0; + + function Comp() { + const [counter, setCounter] = useState(0); + + useEffect(() => { + if (counter === 0) setCounter(1); + effectCount++; + }); + + return null; + } + + render(<Comp />, scratch); + + return scheduleEffectAssert(() => { + expect(effectCount).to.equal(1); + }); + }); + + it('should not reuse functional components with hooks', () => { + let updater = { first: undefined, second: undefined }; + function Foo(props) { + let [v, setter] = useState(0); + updater[props.id] = () => setter(++v); + return <div>{v}</div>; + } + + let updateParent; + class App extends Component { + constructor(props) { + super(props); + this.state = { active: true }; + updateParent = () => this.setState(p => ({ active: !p.active })); + } + + render() { + return ( + <div> + {this.state.active && <Foo id="first" />} + <Foo id="second" /> + </div> + ); + } + } + + render(<App />, scratch); + act(() => updater.second()); + expect(scratch.textContent).to.equal('01'); + + updateParent(); + rerender(); + expect(scratch.textContent).to.equal('1'); + + updateParent(); + rerender(); + + expect(scratch.textContent).to.equal('01'); + }); + + it('should have a right call order with correct dom ref', () => { + let i = 0, + set; + const calls = []; + + function Inner() { + useLayoutEffect(() => { + calls.push('layout inner call ' + scratch.innerHTML); + return () => calls.push('layout inner dispose ' + scratch.innerHTML); + }); + useEffect(() => { + calls.push('effect inner call ' + scratch.innerHTML); + return () => calls.push('effect inner dispose ' + scratch.innerHTML); + }); + return <span>hello {i}</span>; + } + + function Outer() { + i++; + const [state, setState] = useState(false); + set = () => setState(!state); + useLayoutEffect(() => { + calls.push('layout outer call ' + scratch.innerHTML); + return () => calls.push('layout outer dispose ' + scratch.innerHTML); + }); + useEffect(() => { + calls.push('effect outer call ' + scratch.innerHTML); + return () => calls.push('effect outer dispose ' + scratch.innerHTML); + }); + return <Inner />; + } + + act(() => render(<Outer />, scratch)); + expect(calls).to.deep.equal([ + 'layout inner call <span>hello 1</span>', + 'layout outer call <span>hello 1</span>', + 'effect inner call <span>hello 1</span>', + 'effect outer call <span>hello 1</span>' + ]); + + // NOTE: this order is (at the time of writing) intentionally different from + // React. React calls all disposes across all components, and then invokes all + // effects across all components. We call disposes and effects in order of components: + // for each component, call its disposes and then its effects. If presented with a + // compelling use case to support inter-component dispose dependencies, then rewrite this + // test to test React's order. In other words, if there is a use case to support calling + // all disposes across components then re-order the lines below to demonstrate the desired behavior. + + act(() => set()); + expect(calls).to.deep.equal([ + 'layout inner call <span>hello 1</span>', + 'layout outer call <span>hello 1</span>', + 'effect inner call <span>hello 1</span>', + 'effect outer call <span>hello 1</span>', + 'layout inner dispose <span>hello 2</span>', + 'layout inner call <span>hello 2</span>', + 'layout outer dispose <span>hello 2</span>', + 'layout outer call <span>hello 2</span>', + 'effect inner dispose <span>hello 2</span>', + 'effect inner call <span>hello 2</span>', + 'effect outer dispose <span>hello 2</span>', + 'effect outer call <span>hello 2</span>' + ]); + }); +}); diff --git a/preact/hooks/test/browser/errorBoundary.test.js b/preact/hooks/test/browser/errorBoundary.test.js new file mode 100644 index 0000000..4c2f7f3 --- /dev/null +++ b/preact/hooks/test/browser/errorBoundary.test.js @@ -0,0 +1,92 @@ +import { createElement, render } from 'preact'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import { useErrorBoundary } from 'preact/hooks'; +import { setupRerender } from 'preact/test-utils'; + +/** @jsx createElement */ + +describe('errorBoundary', () => { + /** @type {HTMLDivElement} */ + let scratch, rerender; + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('catches errors', () => { + let resetErr, + success = false; + const Throws = () => { + throw new Error('test'); + }; + + const App = props => { + const [err, reset] = useErrorBoundary(); + resetErr = reset; + return err ? <p>Error</p> : success ? <p>Success</p> : <Throws />; + }; + + render(<App />, scratch); + rerender(); + expect(scratch.innerHTML).to.equal('<p>Error</p>'); + + success = true; + resetErr(); + rerender(); + expect(scratch.innerHTML).to.equal('<p>Success</p>'); + }); + + it('calls the errorBoundary callback', () => { + const spy = sinon.spy(); + const error = new Error('test'); + const Throws = () => { + throw error; + }; + + const App = props => { + const [err] = useErrorBoundary(spy); + return err ? <p>Error</p> : <Throws />; + }; + + render(<App />, scratch); + rerender(); + expect(scratch.innerHTML).to.equal('<p>Error</p>'); + expect(spy).to.be.calledOnce; + expect(spy).to.be.calledWith(error); + }); + + it('does not leave a stale closure', () => { + const spy = sinon.spy(), + spy2 = sinon.spy(); + let resetErr; + const error = new Error('test'); + const Throws = () => { + throw error; + }; + + const App = props => { + const [err, reset] = useErrorBoundary(props.onError); + resetErr = reset; + return err ? <p>Error</p> : <Throws />; + }; + + render(<App onError={spy} />, scratch); + rerender(); + expect(scratch.innerHTML).to.equal('<p>Error</p>'); + expect(spy).to.be.calledOnce; + expect(spy).to.be.calledWith(error); + + resetErr(); + render(<App onError={spy2} />, scratch); + rerender(); + expect(scratch.innerHTML).to.equal('<p>Error</p>'); + expect(spy).to.be.calledOnce; + expect(spy2).to.be.calledOnce; + expect(spy2).to.be.calledWith(error); + }); +}); diff --git a/preact/hooks/test/browser/hooks.options.test.js b/preact/hooks/test/browser/hooks.options.test.js new file mode 100644 index 0000000..ca88d1f --- /dev/null +++ b/preact/hooks/test/browser/hooks.options.test.js @@ -0,0 +1,154 @@ +import { + afterDiffSpy, + beforeRenderSpy, + unmountSpy, + hookSpy +} from '../../../test/_util/optionSpies'; + +import { setupRerender, act } from 'preact/test-utils'; +import { createElement, render, createContext, options } from 'preact'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import { + useState, + useReducer, + useEffect, + useLayoutEffect, + useRef, + useImperativeHandle, + useMemo, + useCallback, + useContext, + useErrorBoundary +} from 'preact/hooks'; + +/** @jsx createElement */ + +describe('hook options', () => { + /** @type {HTMLDivElement} */ + let scratch; + + /** @type {() => void} */ + let rerender; + + /** @type {() => void} */ + let increment; + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + + afterDiffSpy.resetHistory(); + unmountSpy.resetHistory(); + beforeRenderSpy.resetHistory(); + hookSpy.resetHistory(); + }); + + afterEach(() => { + teardown(scratch); + }); + + function App() { + const [count, setCount] = useState(0); + increment = () => setCount(prevCount => prevCount + 1); + return <div>{count}</div>; + } + + it('should call old options on mount', () => { + render(<App />, scratch); + + expect(beforeRenderSpy).to.have.been.called; + expect(afterDiffSpy).to.have.been.called; + }); + + it('should call old options.diffed on update', () => { + render(<App />, scratch); + + increment(); + rerender(); + + expect(beforeRenderSpy).to.have.been.called; + expect(afterDiffSpy).to.have.been.called; + }); + + it('should call old options on unmount', () => { + render(<App />, scratch); + render(null, scratch); + + expect(unmountSpy).to.have.been.called; + }); + + it('should detect hooks', () => { + const USE_STATE = 1; + const USE_REDUCER = 2; + const USE_EFFECT = 3; + const USE_LAYOUT_EFFECT = 4; + const USE_REF = 5; + const USE_IMPERATIVE_HANDLE = 6; + const USE_MEMO = 7; + const USE_CALLBACK = 8; + const USE_CONTEXT = 9; + const USE_ERROR_BOUNDARY = 10; + + const Ctx = createContext(null); + + function App() { + useState(0); + useReducer(x => x, 0); + useEffect(() => null, []); + useLayoutEffect(() => null, []); + const ref = useRef(null); + useImperativeHandle(ref, () => null); + useMemo(() => null, []); + useCallback(() => null, []); + useContext(Ctx); + useErrorBoundary(() => null); + } + + render( + <Ctx.Provider value="a"> + <App /> + </Ctx.Provider>, + scratch + ); + + expect(hookSpy.args.map(arg => [arg[1], arg[2]])).to.deep.equal([ + [0, USE_STATE], + [1, USE_REDUCER], + [2, USE_EFFECT], + [3, USE_LAYOUT_EFFECT], + [4, USE_REF], + [5, USE_IMPERATIVE_HANDLE], + [6, USE_MEMO], + [7, USE_CALLBACK], + [8, USE_CONTEXT], + [9, USE_ERROR_BOUNDARY], + // Belongs to useErrorBoundary that uses multiple native hooks. + [10, USE_STATE] + ]); + }); + + describe('Effects', () => { + beforeEach(() => { + options._skipEffects = options.__s = true; + }); + + afterEach(() => { + options._skipEffects = options.__s = false; + }); + + it('should skip effect hooks', () => { + const spy = sinon.spy(); + function App() { + useEffect(spy, []); + useLayoutEffect(spy, []); + return null; + } + + act(() => { + render(<App />, scratch); + }); + + expect(spy.callCount).to.equal(0); + }); + }); +}); diff --git a/preact/hooks/test/browser/useCallback.test.js b/preact/hooks/test/browser/useCallback.test.js new file mode 100644 index 0000000..151fed9 --- /dev/null +++ b/preact/hooks/test/browser/useCallback.test.js @@ -0,0 +1,41 @@ +import { createElement, render } from 'preact'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import { useCallback } from 'preact/hooks'; + +/** @jsx createElement */ + +describe('useCallback', () => { + /** @type {HTMLDivElement} */ + let scratch; + + beforeEach(() => { + scratch = setupScratch(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('only recomputes the callback when inputs change', () => { + const callbacks = []; + + function Comp({ a, b }) { + const cb = useCallback(() => a + b, [a, b]); + callbacks.push(cb); + return null; + } + + render(<Comp a={1} b={1} />, scratch); + render(<Comp a={1} b={1} />, scratch); + + expect(callbacks[0]).to.equal(callbacks[1]); + expect(callbacks[0]()).to.equal(2); + + render(<Comp a={1} b={2} />, scratch); + render(<Comp a={1} b={2} />, scratch); + + expect(callbacks[1]).to.not.equal(callbacks[2]); + expect(callbacks[2]).to.equal(callbacks[3]); + expect(callbacks[2]()).to.equal(3); + }); +}); diff --git a/preact/hooks/test/browser/useContext.test.js b/preact/hooks/test/browser/useContext.test.js new file mode 100644 index 0000000..67b7851 --- /dev/null +++ b/preact/hooks/test/browser/useContext.test.js @@ -0,0 +1,351 @@ +import { createElement, render, createContext, Component } from 'preact'; +import { act } from 'preact/test-utils'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import { useContext, useEffect, useState } from 'preact/hooks'; + +/** @jsx createElement */ + +describe('useContext', () => { + /** @type {HTMLDivElement} */ + let scratch; + + beforeEach(() => { + scratch = setupScratch(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('gets values from context', () => { + const values = []; + const Context = createContext(13); + + function Comp() { + const value = useContext(Context); + values.push(value); + return null; + } + + render(<Comp />, scratch); + render( + <Context.Provider value={42}> + <Comp /> + </Context.Provider>, + scratch + ); + render( + <Context.Provider value={69}> + <Comp /> + </Context.Provider>, + scratch + ); + + expect(values).to.deep.equal([13, 42, 69]); + }); + + it('should use default value', () => { + const Foo = createContext(42); + const spy = sinon.spy(); + + function App() { + spy(useContext(Foo)); + return <div />; + } + + render(<App />, scratch); + expect(spy).to.be.calledWith(42); + }); + + it('should update when value changes with nonUpdating Component on top', done => { + const spy = sinon.spy(); + const Ctx = createContext(0); + + class NoUpdate extends Component { + shouldComponentUpdate() { + return false; + } + render() { + return this.props.children; + } + } + + function App(props) { + return ( + <Ctx.Provider value={props.value}> + <NoUpdate> + <Comp /> + </NoUpdate> + </Ctx.Provider> + ); + } + + function Comp() { + const value = useContext(Ctx); + spy(value); + return <h1>{value}</h1>; + } + + render(<App value={0} />, scratch); + expect(spy).to.be.calledOnce; + expect(spy).to.be.calledWith(0); + render(<App value={1} />, scratch); + + // Wait for enqueued hook update + setTimeout(() => { + // Should not be called a third time + expect(spy).to.be.calledTwice; + expect(spy).to.be.calledWith(1); + done(); + }, 0); + }); + + it('should only update when value has changed', done => { + const spy = sinon.spy(); + const Ctx = createContext(0); + + function App(props) { + return ( + <Ctx.Provider value={props.value}> + <Comp /> + </Ctx.Provider> + ); + } + + function Comp() { + const value = useContext(Ctx); + spy(value); + return <h1>{value}</h1>; + } + + render(<App value={0} />, scratch); + expect(spy).to.be.calledOnce; + expect(spy).to.be.calledWith(0); + render(<App value={1} />, scratch); + + expect(spy).to.be.calledTwice; + expect(spy).to.be.calledWith(1); + + // Wait for enqueued hook update + setTimeout(() => { + // Should not be called a third time + expect(spy).to.be.calledTwice; + done(); + }, 0); + }); + + it('should allow multiple context hooks at the same time', () => { + const Foo = createContext(0); + const Bar = createContext(10); + const spy = sinon.spy(); + const unmountspy = sinon.spy(); + + function Comp() { + const foo = useContext(Foo); + const bar = useContext(Bar); + spy(foo, bar); + useEffect(() => () => unmountspy()); + + return <div />; + } + + render( + <Foo.Provider value={0}> + <Bar.Provider value={10}> + <Comp /> + </Bar.Provider> + </Foo.Provider>, + scratch + ); + + expect(spy).to.be.calledOnce; + expect(spy).to.be.calledWith(0, 10); + + render( + <Foo.Provider value={11}> + <Bar.Provider value={42}> + <Comp /> + </Bar.Provider> + </Foo.Provider>, + scratch + ); + + expect(spy).to.be.calledTwice; + expect(unmountspy).not.to.be.called; + }); + + it('should only subscribe a component once', () => { + const values = []; + const Context = createContext(13); + let provider, subSpy; + + function Comp() { + const value = useContext(Context); + values.push(value); + return null; + } + + render(<Comp />, scratch); + + render( + <Context.Provider ref={p => (provider = p)} value={42}> + <Comp /> + </Context.Provider>, + scratch + ); + subSpy = sinon.spy(provider, 'sub'); + + render( + <Context.Provider value={69}> + <Comp /> + </Context.Provider>, + scratch + ); + expect(subSpy).to.not.have.been.called; + + expect(values).to.deep.equal([13, 42, 69]); + }); + + it('should maintain context', done => { + const context = createContext(null); + const { Provider } = context; + const first = { name: 'first' }; + const second = { name: 'second' }; + + const Input = () => { + const config = useContext(context); + + // Avoid eslint complaining about unused first value + const state = useState('initial'); + const set = state[1]; + + useEffect(() => { + // Schedule the update on the next frame + requestAnimationFrame(() => { + set('irrelevant'); + }); + }, [config]); + + return <div>{config.name}</div>; + }; + + const App = props => { + const [config, setConfig] = useState({}); + + useEffect(() => { + setConfig(props.config); + }, [props.config]); + + return ( + <Provider value={config}> + <Input /> + </Provider> + ); + }; + + act(() => { + render(<App config={first} />, scratch); + + // Create a new div to append the `second` case + const div = scratch.appendChild(document.createElement('div')); + render(<App config={second} />, div); + }); + + // Push the expect into the next frame + requestAnimationFrame(() => { + expect(scratch.innerHTML).equal( + '<div>first</div><div><div>second</div></div>' + ); + done(); + }); + }); + + it('should not rerender consumers that have been unmounted', () => { + const context = createContext(0); + const Provider = context.Provider; + + const Inner = sinon.spy(() => { + const value = useContext(context); + return <div>{value}</div>; + }); + + let toggleConsumer; + let changeValue; + class App extends Component { + constructor() { + super(); + + this.state = { value: 0, show: true }; + changeValue = value => this.setState({ value }); + toggleConsumer = () => this.setState(({ show }) => ({ show: !show })); + } + render(props, state) { + return ( + <Provider value={state.value}> + <div>{state.show ? <Inner /> : null}</div> + </Provider> + ); + } + } + + render(<App />, scratch); + expect(scratch.innerHTML).to.equal('<div><div>0</div></div>'); + expect(Inner).to.have.been.calledOnce; + + act(() => changeValue(1)); + expect(scratch.innerHTML).to.equal('<div><div>1</div></div>'); + expect(Inner).to.have.been.calledTwice; + + act(() => toggleConsumer()); + expect(scratch.innerHTML).to.equal('<div></div>'); + expect(Inner).to.have.been.calledTwice; + + act(() => changeValue(2)); + expect(scratch.innerHTML).to.equal('<div></div>'); + expect(Inner).to.have.been.calledTwice; + }); + + it('should rerender when reset to defaultValue', () => { + const defaultValue = { state: 'hi' }; + const context = createContext(defaultValue); + let set; + + const Consumer = () => { + const ctx = useContext(context); + return <p>{ctx.state}</p>; + }; + + class NoUpdate extends Component { + shouldComponentUpdate() { + return false; + } + + render() { + return <Consumer />; + } + } + + const Provider = () => { + const [state, setState] = useState(defaultValue); + set = setState; + return ( + <context.Provider value={state}> + <NoUpdate /> + </context.Provider> + ); + }; + + render(<Provider />, scratch); + expect(scratch.innerHTML).to.equal('<p>hi</p>'); + + act(() => { + set({ state: 'bye' }); + }); + expect(scratch.innerHTML).to.equal('<p>bye</p>'); + + act(() => { + set(defaultValue); + }); + expect(scratch.innerHTML).to.equal('<p>hi</p>'); + }); +}); diff --git a/preact/hooks/test/browser/useDebugValue.test.js b/preact/hooks/test/browser/useDebugValue.test.js new file mode 100644 index 0000000..d19ff6b --- /dev/null +++ b/preact/hooks/test/browser/useDebugValue.test.js @@ -0,0 +1,71 @@ +import { createElement, render, options } from 'preact'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import { useDebugValue, useState } from 'preact/hooks'; + +/** @jsx createElement */ + +describe('useDebugValue', () => { + /** @type {HTMLDivElement} */ + let scratch; + + beforeEach(() => { + scratch = setupScratch(); + }); + + afterEach(() => { + teardown(scratch); + delete options.useDebugValue; + }); + + it('should do nothing when no options hook is present', () => { + function useFoo() { + useDebugValue('foo'); + return useState(0); + } + + function App() { + let [v] = useFoo(); + return <div>{v}</div>; + } + + expect(() => render(<App />, scratch)).to.not.throw(); + }); + + it('should call options hook with value', () => { + let spy = (options.useDebugValue = sinon.spy()); + + function useFoo() { + useDebugValue('foo'); + return useState(0); + } + + function App() { + let [v] = useFoo(); + return <div>{v}</div>; + } + + render(<App />, scratch); + + expect(spy).to.be.calledOnce; + expect(spy).to.be.calledWith('foo'); + }); + + it('should apply optional formatter', () => { + let spy = (options.useDebugValue = sinon.spy()); + + function useFoo() { + useDebugValue('foo', x => x + 'bar'); + return useState(0); + } + + function App() { + let [v] = useFoo(); + return <div>{v}</div>; + } + + render(<App />, scratch); + + expect(spy).to.be.calledOnce; + expect(spy).to.be.calledWith('foobar'); + }); +}); diff --git a/preact/hooks/test/browser/useEffect.test.js b/preact/hooks/test/browser/useEffect.test.js new file mode 100644 index 0000000..a16d3fc --- /dev/null +++ b/preact/hooks/test/browser/useEffect.test.js @@ -0,0 +1,373 @@ +import { act } from 'preact/test-utils'; +import { createElement, render, Fragment, Component } from 'preact'; +import { useEffect, useState, useRef } from 'preact/hooks'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import { useEffectAssertions } from './useEffectAssertions.test'; +import { scheduleEffectAssert } from '../_util/useEffectUtil'; + +/** @jsx createElement */ + +describe('useEffect', () => { + /** @type {HTMLDivElement} */ + let scratch; + + beforeEach(() => { + scratch = setupScratch(); + }); + + afterEach(() => { + teardown(scratch); + }); + + useEffectAssertions(useEffect, scheduleEffectAssert); + + it('calls the effect immediately if another render is about to start', () => { + const cleanupFunction = sinon.spy(); + const callback = sinon.spy(() => cleanupFunction); + + function Comp() { + useEffect(callback); + return null; + } + + render(<Comp />, scratch); + render(<Comp />, scratch); + + expect(cleanupFunction).to.be.not.called; + expect(callback).to.be.calledOnce; + + render(<Comp />, scratch); + + expect(cleanupFunction).to.be.calledOnce; + expect(callback).to.be.calledTwice; + }); + + it('cancels the effect when the component get unmounted before it had the chance to run it', () => { + const cleanupFunction = sinon.spy(); + const callback = sinon.spy(() => cleanupFunction); + + function Comp() { + useEffect(callback); + return null; + } + + render(<Comp />, scratch); + render(null, scratch); + + return scheduleEffectAssert(() => { + expect(cleanupFunction).to.not.be.called; + expect(callback).to.not.be.called; + }); + }); + + it('should execute multiple effects in same component in the right order', () => { + let executionOrder = []; + const App = ({ i }) => { + executionOrder = []; + useEffect(() => { + executionOrder.push('action1'); + return () => executionOrder.push('cleanup1'); + }, [i]); + useEffect(() => { + executionOrder.push('action2'); + return () => executionOrder.push('cleanup2'); + }, [i]); + return <p>Test</p>; + }; + act(() => render(<App i={0} />, scratch)); + act(() => render(<App i={2} />, scratch)); + expect(executionOrder).to.deep.equal([ + 'cleanup1', + 'cleanup2', + 'action1', + 'action2' + ]); + }); + + it('should execute effects in parent if child throws in effect', async () => { + let executionOrder = []; + + const Child = () => { + useEffect(() => { + executionOrder.push('child'); + throw new Error('test'); + }, []); + + useEffect(() => { + executionOrder.push('child after throw'); + return () => executionOrder.push('child after throw cleanup'); + }, []); + + return <p>Test</p>; + }; + + const Parent = () => { + useEffect(() => { + executionOrder.push('parent'); + return () => executionOrder.push('parent cleanup'); + }, []); + return <Child />; + }; + + class ErrorBoundary extends Component { + componentDidCatch(error) { + this.setState({ error }); + } + + render({ children }, { error }) { + return error ? <div>error</div> : children; + } + } + + act(() => + render( + <ErrorBoundary> + <Parent /> + </ErrorBoundary>, + scratch + ) + ); + + expect(executionOrder).to.deep.equal(['child', 'parent', 'parent cleanup']); + expect(scratch.innerHTML).to.equal('<div>error</div>'); + }); + + it('should throw an error upwards', () => { + const spy = sinon.spy(); + let errored = false; + + const Page1 = () => { + const [state, setState] = useState('loading'); + useEffect(() => { + setState('loaded'); + }, []); + return <p>{state}</p>; + }; + + const Page2 = () => { + useEffect(() => { + throw new Error('err'); + }, []); + return <p>invisible</p>; + }; + + class App extends Component { + componentDidCatch(err) { + spy(); + errored = err; + this.forceUpdate(); + } + + render(props, state) { + if (errored) { + return <p>Error</p>; + } + + return <Fragment>{props.page === 1 ? <Page1 /> : <Page2 />}</Fragment>; + } + } + + act(() => render(<App page={1} />, scratch)); + expect(spy).to.not.be.called; + expect(scratch.innerHTML).to.equal('<p>loaded</p>'); + + act(() => render(<App page={2} />, scratch)); + expect(spy).to.be.calledOnce; + expect(scratch.innerHTML).to.equal('<p>Error</p>'); + errored = false; + + act(() => render(<App page={1} />, scratch)); + expect(spy).to.be.calledOnce; + expect(scratch.innerHTML).to.equal('<p>loaded</p>'); + }); + + it('should throw an error upwards from return', () => { + const spy = sinon.spy(); + let errored = false; + + const Page1 = () => { + const [state, setState] = useState('loading'); + useEffect(() => { + setState('loaded'); + }, []); + return <p>{state}</p>; + }; + + const Page2 = () => { + useEffect(() => { + return () => { + throw new Error('err'); + }; + }, []); + return <p>Load</p>; + }; + + class App extends Component { + componentDidCatch(err) { + spy(); + errored = err; + this.forceUpdate(); + } + + render(props, state) { + if (errored) { + return <p>Error</p>; + } + + return <Fragment>{props.page === 1 ? <Page1 /> : <Page2 />}</Fragment>; + } + } + + act(() => render(<App page={2} />, scratch)); + expect(scratch.innerHTML).to.equal('<p>Load</p>'); + + act(() => render(<App page={1} />, scratch)); + expect(spy).to.be.calledOnce; + expect(scratch.innerHTML).to.equal('<p>Error</p>'); + }); + + it('catches errors when error is invoked during render', () => { + const spy = sinon.spy(); + let errored; + + function Comp() { + useEffect(() => { + throw new Error('hi'); + }); + return null; + } + + class App extends Component { + componentDidCatch(err) { + spy(); + errored = err; + this.forceUpdate(); + } + + render(props, state) { + if (errored) { + return <p>Error</p>; + } + + return <Comp />; + } + } + + render(<App />, scratch); + act(() => { + render(<App />, scratch); + }); + expect(spy).to.be.calledOnce; + expect(errored) + .to.be.an('Error') + .with.property('message', 'hi'); + expect(scratch.innerHTML).to.equal('<p>Error</p>'); + }); + + it('should allow creating a new root', () => { + const root = document.createElement('div'); + const global = document.createElement('div'); + scratch.appendChild(root); + scratch.appendChild(global); + + const Modal = props => { + let [, setCanProceed] = useState(true); + let ChildProp = props.content; + + return ( + <div> + <ChildProp setCanProceed={setCanProceed} /> + </div> + ); + }; + + const Inner = () => { + useEffect(() => { + render(<div>global</div>, global); + }, []); + + return <div>Inner</div>; + }; + + act(() => { + render( + <Modal + content={props => { + props.setCanProceed(false); + return <Inner />; + }} + />, + root + ); + }); + + expect(scratch.innerHTML).to.equal( + '<div><div><div>Inner</div></div></div><div><div>global</div></div>' + ); + }); + + it('should not crash when effect returns truthy non-function value', () => { + const callback = sinon.spy(() => 'truthy'); + function Comp() { + useEffect(callback); + return null; + } + + render(<Comp />, scratch); + render(<Comp />, scratch); + + expect(callback).to.have.been.calledOnce; + + render(<div>Replacement</div>, scratch); + }); + + it('support render roots from an effect', async () => { + let promise, increment; + + const Counter = () => { + const [count, setCount] = useState(0); + const renderRoot = useRef(); + useEffect(() => { + if (count > 0) { + const div = renderRoot.current; + return () => render(<Dummy />, div); + } + return () => 'test'; + }, [count]); + + increment = () => { + setCount(x => x + 1); + promise = new Promise(res => { + setTimeout(() => { + setCount(x => x + 1); + res(); + }); + }); + }; + + return ( + <div> + <div>Count: {count}</div> + <div ref={renderRoot} /> + </div> + ); + }; + + const Dummy = () => <div>dummy</div>; + + render(<Counter />, scratch); + + expect(scratch.innerHTML).to.equal( + '<div><div>Count: 0</div><div></div></div>' + ); + + act(() => { + increment(); + }); + await promise; + act(() => {}); + expect(scratch.innerHTML).to.equal( + '<div><div>Count: 2</div><div><div>dummy</div></div></div>' + ); + }); +}); diff --git a/preact/hooks/test/browser/useEffectAssertions.test.js b/preact/hooks/test/browser/useEffectAssertions.test.js new file mode 100644 index 0000000..74ba232 --- /dev/null +++ b/preact/hooks/test/browser/useEffectAssertions.test.js @@ -0,0 +1,142 @@ +import { setupRerender } from 'preact/test-utils'; +import { createElement, render } from 'preact'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; + +/** @jsx createElement */ + +// Common behaviors between all effect hooks +export function useEffectAssertions(useEffect, scheduleEffectAssert) { + /** @type {HTMLDivElement} */ + let scratch; + + /** @type {() => void} */ + let rerender; + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('performs the effect after every render by default', () => { + const callback = sinon.spy(); + + function Comp() { + useEffect(callback); + return null; + } + + render(<Comp />, scratch); + + return scheduleEffectAssert(() => expect(callback).to.be.calledOnce) + .then(() => scheduleEffectAssert(() => expect(callback).to.be.calledOnce)) + .then(() => render(<Comp />, scratch)) + .then(() => + scheduleEffectAssert(() => expect(callback).to.be.calledTwice) + ); + }); + + it('performs the effect only if one of the inputs changed', () => { + const callback = sinon.spy(); + + function Comp(props) { + useEffect(callback, [props.a, props.b]); + return null; + } + + render(<Comp a={1} b={2} />, scratch); + + return scheduleEffectAssert(() => expect(callback).to.be.calledOnce) + .then(() => render(<Comp a={1} b={2} />, scratch)) + .then(() => scheduleEffectAssert(() => expect(callback).to.be.calledOnce)) + .then(() => render(<Comp a={2} b={2} />, scratch)) + .then(() => + scheduleEffectAssert(() => expect(callback).to.be.calledTwice) + ) + .then(() => render(<Comp a={2} b={2} />, scratch)) + .then(() => + scheduleEffectAssert(() => expect(callback).to.be.calledTwice) + ); + }); + + it('performs the effect at mount time and never again if an empty input Array is passed', () => { + const callback = sinon.spy(); + + function Comp() { + useEffect(callback, []); + return null; + } + + render(<Comp />, scratch); + render(<Comp />, scratch); + + expect(callback).to.be.calledOnce; + + return scheduleEffectAssert(() => expect(callback).to.be.calledOnce) + .then(() => render(<Comp />, scratch)) + .then(() => + scheduleEffectAssert(() => expect(callback).to.be.calledOnce) + ); + }); + + it('calls the cleanup function followed by the effect after each render', () => { + const cleanupFunction = sinon.spy(); + const callback = sinon.spy(() => cleanupFunction); + + function Comp() { + useEffect(callback); + return null; + } + + render(<Comp />, scratch); + + return scheduleEffectAssert(() => { + expect(cleanupFunction).to.be.not.called; + expect(callback).to.be.calledOnce; + }) + .then(() => scheduleEffectAssert(() => expect(callback).to.be.calledOnce)) + .then(() => render(<Comp />, scratch)) + .then(() => + scheduleEffectAssert(() => { + expect(cleanupFunction).to.be.calledOnce; + expect(callback).to.be.calledTwice; + expect(callback.lastCall.calledAfter(cleanupFunction.lastCall)); + }) + ); + }); + + it('cleanups the effect when the component get unmounted if the effect was called before', () => { + const cleanupFunction = sinon.spy(); + const callback = sinon.spy(() => cleanupFunction); + + function Comp() { + useEffect(callback); + return null; + } + + render(<Comp />, scratch); + + return scheduleEffectAssert(() => { + render(null, scratch); + rerender(); + expect(cleanupFunction).to.be.calledOnce; + }); + }); + + it('works with closure effect callbacks capturing props', () => { + const values = []; + + function Comp(props) { + useEffect(() => values.push(props.value)); + return null; + } + + render(<Comp value={1} />, scratch); + render(<Comp value={2} />, scratch); + + return scheduleEffectAssert(() => expect(values).to.deep.equal([1, 2])); + }); +} diff --git a/preact/hooks/test/browser/useImperativeHandle.test.js b/preact/hooks/test/browser/useImperativeHandle.test.js new file mode 100644 index 0000000..52cc073 --- /dev/null +++ b/preact/hooks/test/browser/useImperativeHandle.test.js @@ -0,0 +1,182 @@ +import { createElement, render } from 'preact'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import { useImperativeHandle, useRef, useState } from 'preact/hooks'; +import { setupRerender } from 'preact/test-utils'; + +/** @jsx createElement */ + +describe('useImperativeHandle', () => { + /** @type {HTMLDivElement} */ + let scratch; + + /** @type {() => void} */ + let rerender; + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('Mutates given ref', () => { + let ref; + + function Comp() { + ref = useRef({}); + useImperativeHandle(ref, () => ({ test: () => 'test' }), []); + return <p>Test</p>; + } + + render(<Comp />, scratch); + expect(ref.current).to.have.property('test'); + expect(ref.current.test()).to.equal('test'); + }); + + it('calls createHandle after every render by default', () => { + let ref, + createHandleSpy = sinon.spy(); + + function Comp() { + ref = useRef({}); + useImperativeHandle(ref, createHandleSpy); + return <p>Test</p>; + } + + render(<Comp />, scratch); + expect(createHandleSpy).to.have.been.calledOnce; + + render(<Comp />, scratch); + expect(createHandleSpy).to.have.been.calledTwice; + + render(<Comp />, scratch); + expect(createHandleSpy).to.have.been.calledThrice; + }); + + it('calls createHandle only on mount if an empty array is passed', () => { + let ref, + createHandleSpy = sinon.spy(); + + function Comp() { + ref = useRef({}); + useImperativeHandle(ref, createHandleSpy, []); + return <p>Test</p>; + } + + render(<Comp />, scratch); + expect(createHandleSpy).to.have.been.calledOnce; + + render(<Comp />, scratch); + expect(createHandleSpy).to.have.been.calledOnce; + }); + + it('Updates given ref when args change', () => { + let ref, + createHandleSpy = sinon.spy(); + + function Comp({ a }) { + ref = useRef({}); + useImperativeHandle( + ref, + () => { + createHandleSpy(); + return { test: () => 'test' + a }; + }, + [a] + ); + return <p>Test</p>; + } + + render(<Comp a={0} />, scratch); + expect(createHandleSpy).to.have.been.calledOnce; + expect(ref.current).to.have.property('test'); + expect(ref.current.test()).to.equal('test0'); + + render(<Comp a={1} />, scratch); + expect(createHandleSpy).to.have.been.calledTwice; + expect(ref.current).to.have.property('test'); + expect(ref.current.test()).to.equal('test1'); + + render(<Comp a={0} />, scratch); + expect(createHandleSpy).to.have.been.calledThrice; + expect(ref.current).to.have.property('test'); + expect(ref.current.test()).to.equal('test0'); + }); + + it('Updates given ref when passed-in ref changes', () => { + let ref1, ref2; + + /** @type {(arg: any) => void} */ + let setRef; + + /** @type {() => void} */ + let updateState; + + const createHandleSpy = sinon.spy(() => ({ + test: () => 'test' + })); + + function Comp() { + ref1 = useRef({}); + ref2 = useRef({}); + + const [ref, setRefInternal] = useState(ref1); + setRef = setRefInternal; + + let [value, setState] = useState(0); + updateState = () => setState((value + 1) % 2); + + useImperativeHandle(ref, createHandleSpy, []); + return <p>Test</p>; + } + + render(<Comp a={0} />, scratch); + expect(createHandleSpy).to.have.been.calledOnce; + + updateState(); + rerender(); + expect(createHandleSpy).to.have.been.calledOnce; + + setRef(ref2); + rerender(); + expect(createHandleSpy).to.have.been.calledTwice; + + updateState(); + rerender(); + expect(createHandleSpy).to.have.been.calledTwice; + + setRef(ref1); + rerender(); + expect(createHandleSpy).to.have.been.calledThrice; + }); + + it('should not update ref when args have not changed', () => { + let ref, + createHandleSpy = sinon.spy(() => ({ test: () => 'test' })); + + function Comp() { + ref = useRef({}); + useImperativeHandle(ref, createHandleSpy, [1]); + return <p>Test</p>; + } + + render(<Comp />, scratch); + expect(createHandleSpy).to.have.been.calledOnce; + expect(ref.current.test()).to.equal('test'); + + render(<Comp />, scratch); + expect(createHandleSpy).to.have.been.calledOnce; + expect(ref.current.test()).to.equal('test'); + }); + + it('should not throw with nullish ref', () => { + function Comp() { + useImperativeHandle(null, () => ({ test: () => 'test' }), [1]); + return <p>Test</p>; + } + + expect(() => render(<Comp />, scratch)).to.not.throw(); + }); +}); diff --git a/preact/hooks/test/browser/useLayoutEffect.test.js b/preact/hooks/test/browser/useLayoutEffect.test.js new file mode 100644 index 0000000..72ab949 --- /dev/null +++ b/preact/hooks/test/browser/useLayoutEffect.test.js @@ -0,0 +1,326 @@ +import { act } from 'preact/test-utils'; +import { createElement, render, Fragment, Component } from 'preact'; +import { + setupScratch, + teardown, + serializeHtml +} from '../../../test/_util/helpers'; +import { useEffectAssertions } from './useEffectAssertions.test'; +import { useLayoutEffect, useRef, useState } from 'preact/hooks'; + +/** @jsx createElement */ + +describe('useLayoutEffect', () => { + /** @type {HTMLDivElement} */ + let scratch; + + beforeEach(() => { + scratch = setupScratch(); + }); + + afterEach(() => { + teardown(scratch); + }); + + // Layout effects fire synchronously + const scheduleEffectAssert = assertFn => + new Promise(resolve => { + assertFn(); + resolve(); + }); + + useEffectAssertions(useLayoutEffect, scheduleEffectAssert); + + it('calls the effect immediately after render', () => { + const cleanupFunction = sinon.spy(); + const callback = sinon.spy(() => cleanupFunction); + + function Comp() { + useLayoutEffect(callback); + return null; + } + + render(<Comp />, scratch); + render(<Comp />, scratch); + + expect(cleanupFunction).to.be.calledOnce; + expect(callback).to.be.calledTwice; + + render(<Comp />, scratch); + + expect(cleanupFunction).to.be.calledTwice; + expect(callback).to.be.calledThrice; + }); + + it('works on a nested component', () => { + const callback = sinon.spy(); + + function Parent() { + return ( + <div> + <Child /> + </div> + ); + } + + function Child() { + useLayoutEffect(callback); + return null; + } + + render(<Parent />, scratch); + + expect(callback).to.be.calledOnce; + }); + + it('should execute multiple layout effects in same component in the right order', () => { + let executionOrder = []; + const App = ({ i }) => { + executionOrder = []; + useLayoutEffect(() => { + executionOrder.push('action1'); + return () => executionOrder.push('cleanup1'); + }, [i]); + useLayoutEffect(() => { + executionOrder.push('action2'); + return () => executionOrder.push('cleanup2'); + }, [i]); + return <p>Test</p>; + }; + render(<App i={0} />, scratch); + render(<App i={2} />, scratch); + expect(executionOrder).to.deep.equal([ + 'cleanup1', + 'cleanup2', + 'action1', + 'action2' + ]); + }); + + it('should correctly display DOM', () => { + function AutoResizeTextareaLayoutEffect(props) { + const ref = useRef(null); + useLayoutEffect(() => { + // IE & Edge put textarea's value as child of textarea when reading innerHTML so use + // cross browser serialize helper + const actualHtml = serializeHtml(scratch); + const expectedHTML = `<div class="${props.value}"><p>${props.value}</p><textarea></textarea></div>`; + expect(actualHtml).to.equal(expectedHTML); + expect(document.body.contains(ref.current)).to.equal(true); + }); + return ( + <Fragment> + <p>{props.value}</p> + <textarea ref={ref} value={props.value} onChange={props.onChange} /> + </Fragment> + ); + } + + function App(props) { + return ( + <div class={props.value}> + <AutoResizeTextareaLayoutEffect {...props} /> + </div> + ); + } + + render(<App value="hi" />, scratch); + render(<App value="hii" />, scratch); + }); + + it('should invoke layout effects after subtree is fully connected', () => { + let ref; + let layoutEffect = sinon.spy(() => { + const isConnected = document.body.contains(ref.current); + expect(isConnected).to.equal(true, 'isConnected'); + }); + + function Inner() { + ref = useRef(null); + useLayoutEffect(layoutEffect); + return ( + <Fragment> + <textarea ref={ref} /> + <span>hello</span>; + </Fragment> + ); + } + + function Outer() { + return ( + <div> + <Inner /> + </div> + ); + } + + render(<Outer />, scratch); + expect(layoutEffect).to.have.been.calledOnce; + }); + + // TODO: Make this test pass to resolve issue #1886 + it.skip('should call effects correctly when unmounting', () => { + let onClick, calledFoo, calledBar, calledFooCleanup, calledBarCleanup; + + const Foo = () => { + useLayoutEffect(() => { + if (!calledFoo) calledFoo = scratch.innerHTML; + return () => { + if (!calledFooCleanup) calledFooCleanup = scratch.innerHTML; + }; + }, []); + + return ( + <div> + <p>Foo</p> + </div> + ); + }; + + const Bar = () => { + useLayoutEffect(() => { + if (!calledBar) calledBar = scratch.innerHTML; + return () => { + if (!calledBarCleanup) calledBarCleanup = scratch.innerHTML; + }; + }, []); + + return ( + <div> + <p>Bar</p> + </div> + ); + }; + + function App() { + const [current, setCurrent] = useState('/foo'); + + onClick = () => setCurrent(current === '/foo' ? '/bar' : '/foo'); + + return ( + <Fragment> + <button onClick={onClick}>next</button> + + {current === '/foo' && <Foo />} + {current === '/bar' && <Bar />} + </Fragment> + ); + } + + render(<App />, scratch); + expect(calledFoo).to.equal( + '<button>next</button><div><p>Foo</p></div>', + 'calledFoo' + ); + + act(() => onClick()); + expect(calledFooCleanup).to.equal( + '<button>next</button><div><p>Bar</p></div>', + 'calledFooCleanup' + ); + expect(calledBar).to.equal( + '<button>next</button><div><p>Bar</p></div>', + 'calledBar' + ); + + act(() => onClick()); + expect(calledBarCleanup).to.equal( + '<button>next</button><div><p>Foo</p></div>', + 'calledBarCleanup' + ); + }); + + it('should throw an error upwards', () => { + const spy = sinon.spy(); + let errored = false; + + const Page1 = () => { + const [state, setState] = useState('loading'); + useLayoutEffect(() => { + setState('loaded'); + }, []); + return <p>{state}</p>; + }; + + const Page2 = () => { + useLayoutEffect(() => { + throw new Error('err'); + }, []); + return <p>invisible</p>; + }; + + class App extends Component { + componentDidCatch(err) { + spy(); + errored = err; + this.forceUpdate(); + } + + render(props, state) { + if (errored) { + return <p>Error</p>; + } + + return <Fragment>{props.page === 1 ? <Page1 /> : <Page2 />}</Fragment>; + } + } + + act(() => render(<App page={1} />, scratch)); + expect(spy).to.not.be.called; + expect(scratch.innerHTML).to.equal('<p>loaded</p>'); + + act(() => render(<App page={2} />, scratch)); + expect(spy).to.be.calledOnce; + expect(scratch.innerHTML).to.equal('<p>Error</p>'); + errored = false; + + act(() => render(<App page={1} />, scratch)); + expect(spy).to.be.calledOnce; + expect(scratch.innerHTML).to.equal('<p>loaded</p>'); + }); + + it('should throw an error upwards from return', () => { + const spy = sinon.spy(); + let errored = false; + + const Page1 = () => { + const [state, setState] = useState('loading'); + useLayoutEffect(() => { + setState('loaded'); + }, []); + return <p>{state}</p>; + }; + + const Page2 = () => { + useLayoutEffect(() => { + return () => { + throw new Error('err'); + }; + }, []); + return <p>Load</p>; + }; + + class App extends Component { + componentDidCatch(err) { + spy(); + errored = err; + this.forceUpdate(); + } + + render(props, state) { + if (errored) { + return <p>Error</p>; + } + + return <Fragment>{props.page === 1 ? <Page1 /> : <Page2 />}</Fragment>; + } + } + + act(() => render(<App page={2} />, scratch)); + expect(scratch.innerHTML).to.equal('<p>Load</p>'); + + act(() => render(<App page={1} />, scratch)); + expect(spy).to.be.calledOnce; + expect(scratch.innerHTML).to.equal('<p>Error</p>'); + }); +}); diff --git a/preact/hooks/test/browser/useMemo.test.js b/preact/hooks/test/browser/useMemo.test.js new file mode 100644 index 0000000..68fb72e --- /dev/null +++ b/preact/hooks/test/browser/useMemo.test.js @@ -0,0 +1,125 @@ +import { createElement, render } from 'preact'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import { useMemo } from 'preact/hooks'; + +/** @jsx createElement */ + +describe('useMemo', () => { + /** @type {HTMLDivElement} */ + let scratch; + + beforeEach(() => { + scratch = setupScratch(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('only recomputes the result when inputs change', () => { + let memoFunction = sinon.spy((a, b) => a + b); + const results = []; + + function Comp({ a, b }) { + const result = useMemo(() => memoFunction(a, b), [a, b]); + results.push(result); + return null; + } + + render(<Comp a={1} b={1} />, scratch); + render(<Comp a={1} b={1} />, scratch); + + expect(results).to.deep.equal([2, 2]); + expect(memoFunction).to.have.been.calledOnce; + + render(<Comp a={1} b={2} />, scratch); + render(<Comp a={1} b={2} />, scratch); + + expect(results).to.deep.equal([2, 2, 3, 3]); + expect(memoFunction).to.have.been.calledTwice; + }); + + it('should rerun when deps length changes', () => { + let memoFunction = sinon.spy(() => 1 + 2); + + function Comp({ all }) { + const deps = [1, all && 2].filter(Boolean); + const result = useMemo(() => memoFunction(), deps); + return result; + } + + render(<Comp all />, scratch); + expect(memoFunction).to.have.been.calledOnce; + render(<Comp all={false} />, scratch); + expect(memoFunction).to.have.been.calledTwice; + }); + + it('should rerun when first run threw an error', () => { + let hasThrown = false; + let memoFunction = sinon.spy(() => { + if (!hasThrown) { + hasThrown = true; + throw new Error('test'); + } else { + return 3; + } + }); + + function Comp() { + const result = useMemo(() => memoFunction(), []); + return result; + } + + expect(() => render(<Comp />, scratch)).to.throw('test'); + expect(memoFunction).to.have.been.calledOnce; + expect(() => render(<Comp />, scratch)).not.to.throw(); + expect(memoFunction).to.have.been.calledTwice; + }); + + it('short circuits diffing for memoized components', () => { + let spy = sinon.spy(); + let spy2 = sinon.spy(); + const X = ({ count }) => { + spy(); + return <span>{count}</span>; + }; + + const Y = ({ count }) => { + spy2(); + return <p>{count}</p>; + }; + + const App = ({ x }) => { + const y = useMemo(() => <Y count={x} />, [x]); + return ( + <div> + <X count={x} /> + {y} + </div> + ); + }; + + render(<App x={0} />, scratch); + expect(spy).to.be.calledOnce; + expect(spy2).to.be.calledOnce; + expect(scratch.innerHTML).to.equal('<div><span>0</span><p>0</p></div>'); + + render(<App x={0} />, scratch); + expect(spy).to.be.calledTwice; + expect(spy2).to.be.calledOnce; + expect(scratch.innerHTML).to.equal('<div><span>0</span><p>0</p></div>'); + + render(<App x={1} />, scratch); + expect(spy).to.be.calledThrice; + expect(spy2).to.be.calledTwice; + expect(scratch.innerHTML).to.equal('<div><span>1</span><p>1</p></div>'); + + render(<App x={1} />, scratch); + expect(spy2).to.be.calledTwice; + expect(scratch.innerHTML).to.equal('<div><span>1</span><p>1</p></div>'); + + render(<App x={2} />, scratch); + expect(spy2).to.be.calledThrice; + expect(scratch.innerHTML).to.equal('<div><span>2</span><p>2</p></div>'); + }); +}); diff --git a/preact/hooks/test/browser/useReducer.test.js b/preact/hooks/test/browser/useReducer.test.js new file mode 100644 index 0000000..4b9d393 --- /dev/null +++ b/preact/hooks/test/browser/useReducer.test.js @@ -0,0 +1,214 @@ +import { setupRerender, act } from 'preact/test-utils'; +import { createElement, render, createContext } from 'preact'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import { useReducer, useEffect, useContext } from 'preact/hooks'; + +/** @jsx createElement */ + +describe('useReducer', () => { + /** @type {HTMLDivElement} */ + let scratch; + + /** @type {() => void} */ + let rerender; + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('rerenders when dispatching an action', () => { + const states = []; + let _dispatch; + + const initState = { count: 0 }; + + function reducer(state, action) { + switch (action.type) { + case 'increment': + return { count: state.count + action.by }; + } + } + + function Comp() { + const [state, dispatch] = useReducer(reducer, initState); + _dispatch = dispatch; + states.push(state); + return null; + } + + render(<Comp />, scratch); + + _dispatch({ type: 'increment', by: 10 }); + rerender(); + + expect(states).to.deep.equal([{ count: 0 }, { count: 10 }]); + }); + + it('can be dispatched by another component', () => { + const initState = { count: 0 }; + + function reducer(state, action) { + switch (action.type) { + case 'increment': + return { count: state.count + action.by }; + } + } + + function ReducerComponent() { + const [state, dispatch] = useReducer(reducer, initState); + return ( + <div> + <p>Count: {state.count}</p> + <DispatchComponent dispatch={dispatch} /> + </div> + ); + } + + function DispatchComponent(props) { + return ( + <button onClick={() => props.dispatch({ type: 'increment', by: 10 })}> + Increment + </button> + ); + } + + render(<ReducerComponent />, scratch); + expect(scratch.textContent).to.include('Count: 0'); + + const button = scratch.querySelector('button'); + button.click(); + + rerender(); + expect(scratch.textContent).to.include('Count: 10'); + }); + + it('can lazily initialize its state with an action', () => { + const states = []; + let _dispatch; + + function init(initialCount) { + return { count: initialCount }; + } + + function reducer(state, action) { + switch (action.type) { + case 'increment': + return { count: state.count + action.by }; + } + } + + function Comp({ initCount }) { + const [state, dispatch] = useReducer(reducer, initCount, init); + _dispatch = dispatch; + states.push(state); + return null; + } + + render(<Comp initCount={10} />, scratch); + + _dispatch({ type: 'increment', by: 10 }); + rerender(); + + expect(states).to.deep.equal([{ count: 10 }, { count: 20 }]); + }); + + it('provides a stable reference for dispatch', () => { + const dispatches = []; + let _dispatch; + + const initState = { count: 0 }; + + function reducer(state, action) { + switch (action.type) { + case 'increment': + return { count: state.count + action.by }; + } + } + + function Comp() { + const [, dispatch] = useReducer(reducer, initState); + _dispatch = dispatch; + dispatches.push(dispatch); + return null; + } + + render(<Comp />, scratch); + + _dispatch({ type: 'increment', by: 10 }); + rerender(); + + expect(dispatches[0]).to.equal(dispatches[1]); + }); + + it('uses latest reducer', () => { + const states = []; + let _dispatch; + + const initState = { count: 0 }; + + function Comp({ increment }) { + const [state, dispatch] = useReducer(function(state, action) { + switch (action.type) { + case 'increment': + return { count: state.count + increment }; + } + }, initState); + _dispatch = dispatch; + states.push(state); + return null; + } + + render(<Comp increment={10} />, scratch); + + render(<Comp increment={20} />, scratch); + + _dispatch({ type: 'increment' }); + rerender(); + + expect(states).to.deep.equal([{ count: 0 }, { count: 0 }, { count: 20 }]); + }); + + // Relates to #2549 + it('should not mutate the hookState', () => { + const reducer = (state, action) => ({ + ...state, + innerMessage: action.payload + }); + + const ContextMessage = ({ context }) => { + const [{ innerMessage }, dispatch] = useContext(context); + useEffect(() => { + dispatch({ payload: 'message' }); + }, []); + + return innerMessage && <p>{innerMessage}</p>; + }; + + const Wrapper = ({ children }) => <div>{children}</div>; + + const badContextDefault = {}; + const BadContext = createContext({}); + + const Abstraction = ({ reducer, defaultState, children }) => ( + <BadContext.Provider value={useReducer(reducer, defaultState)}> + <Wrapper>{children}</Wrapper> + </BadContext.Provider> + ); + + const App = () => ( + <Abstraction reducer={reducer} defaultState={badContextDefault}> + <ContextMessage context={BadContext} /> + </Abstraction> + ); + + act(() => { + render(<App />, scratch); + }); + expect(scratch.innerHTML).to.equal('<div><p>message</p></div>'); + }); +}); diff --git a/preact/hooks/test/browser/useRef.test.js b/preact/hooks/test/browser/useRef.test.js new file mode 100644 index 0000000..7d7a657 --- /dev/null +++ b/preact/hooks/test/browser/useRef.test.js @@ -0,0 +1,50 @@ +import { createElement, render } from 'preact'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import { useRef } from 'preact/hooks'; + +/** @jsx createElement */ + +describe('useRef', () => { + /** @type {HTMLDivElement} */ + let scratch; + + beforeEach(() => { + scratch = setupScratch(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('provides a stable reference', () => { + const values = []; + + function Comp() { + const ref = useRef(1); + values.push(ref.current); + ref.current = 2; + return null; + } + + render(<Comp />, scratch); + render(<Comp />, scratch); + + expect(values).to.deep.equal([1, 2]); + }); + + it('defaults to undefined', () => { + const values = []; + + function Comp() { + const ref = useRef(); + values.push(ref.current); + ref.current = 2; + return null; + } + + render(<Comp />, scratch); + render(<Comp />, scratch); + + expect(values).to.deep.equal([undefined, 2]); + }); +}); diff --git a/preact/hooks/test/browser/useState.test.js b/preact/hooks/test/browser/useState.test.js new file mode 100644 index 0000000..c65a21b --- /dev/null +++ b/preact/hooks/test/browser/useState.test.js @@ -0,0 +1,214 @@ +import { setupRerender } from 'preact/test-utils'; +import { createElement, render } from 'preact'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import { useState } from 'preact/hooks'; + +/** @jsx createElement */ + +describe('useState', () => { + /** @type {HTMLDivElement} */ + let scratch; + + /** @type {() => void} */ + let rerender; + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('serves the same state across render calls', () => { + const stateHistory = []; + + function Comp() { + const [state] = useState({ a: 1 }); + stateHistory.push(state); + return null; + } + + render(<Comp />, scratch); + render(<Comp />, scratch); + + expect(stateHistory).to.deep.equal([{ a: 1 }, { a: 1 }]); + expect(stateHistory[0]).to.equal(stateHistory[1]); + }); + + it('can initialize the state via a function', () => { + const initState = sinon.spy(() => 1); + + function Comp() { + useState(initState); + return null; + } + + render(<Comp />, scratch); + render(<Comp />, scratch); + + expect(initState).to.be.calledOnce; + }); + + it('does not rerender on equal state', () => { + let lastState; + let doSetState; + + const Comp = sinon.spy(() => { + const [state, setState] = useState(0); + lastState = state; + doSetState = setState; + return null; + }); + + render(<Comp />, scratch); + expect(lastState).to.equal(0); + expect(Comp).to.be.calledOnce; + + doSetState(0); + rerender(); + expect(lastState).to.equal(0); + expect(Comp).to.be.calledOnce; + + doSetState(() => 0); + rerender(); + expect(lastState).to.equal(0); + expect(Comp).to.be.calledOnce; + }); + + it('rerenders when setting the state', () => { + let lastState; + let doSetState; + + const Comp = sinon.spy(() => { + const [state, setState] = useState(0); + lastState = state; + doSetState = setState; + return null; + }); + + render(<Comp />, scratch); + expect(lastState).to.equal(0); + expect(Comp).to.be.calledOnce; + + doSetState(1); + rerender(); + expect(lastState).to.equal(1); + expect(Comp).to.be.calledTwice; + + // Updater function style + doSetState(current => current * 10); + rerender(); + expect(lastState).to.equal(10); + expect(Comp).to.be.calledThrice; + }); + + it('can be set by another component', () => { + function StateContainer() { + const [count, setCount] = useState(0); + return ( + <div> + <p>Count: {count}</p> + <Increment increment={() => setCount(c => c + 10)} /> + </div> + ); + } + + function Increment(props) { + return <button onClick={props.increment}>Increment</button>; + } + + render(<StateContainer />, scratch); + expect(scratch.textContent).to.include('Count: 0'); + + const button = scratch.querySelector('button'); + button.click(); + + rerender(); + expect(scratch.textContent).to.include('Count: 10'); + }); + + it('should correctly initialize', () => { + let scopedThing = 'hi'; + let arg; + + function useSomething() { + const args = useState(setup); + function setup(thing = scopedThing) { + arg = thing; + return thing; + } + return args; + } + + const App = () => { + const [state] = useSomething(); + return <p>{state}</p>; + }; + + render(<App />, scratch); + + expect(arg).to.equal('hi'); + expect(scratch.innerHTML).to.equal('<p>hi</p>'); + }); + + it('should correctly re-initialize when first run threw an error', () => { + let hasThrown = false; + let setup = sinon.spy(() => { + if (!hasThrown) { + hasThrown = true; + throw new Error('test'); + } else { + return 'hi'; + } + }); + + const App = () => { + const state = useState(setup)[0]; + return <p>{state}</p>; + }; + + expect(() => render(<App />, scratch)).to.throw('test'); + expect(setup).to.have.been.calledOnce; + expect(() => render(<App />, scratch)).not.to.throw(); + expect(setup).to.have.been.calledTwice; + expect(scratch.innerHTML).to.equal('<p>hi</p>'); + }); + + it('should handle queued useState', () => { + function Message({ message, onClose }) { + const [isVisible, setVisible] = useState(Boolean(message)); + const [prevMessage, setPrevMessage] = useState(message); + + if (message !== prevMessage) { + setPrevMessage(message); + setVisible(Boolean(message)); + } + + if (!isVisible) { + return null; + } + return <p onClick={onClose}>{message}</p>; + } + + function App() { + const [message, setMessage] = useState('Click Here!!'); + return ( + <Message + onClose={() => { + setMessage(''); + }} + message={message} + /> + ); + } + + render(<App />, scratch); + expect(scratch.textContent).to.equal('Click Here!!'); + const text = scratch.querySelector('p'); + text.click(); + rerender(); + expect(scratch.innerHTML).to.equal(''); + }); +}); |