summaryrefslogtreecommitdiff
path: root/preact/hooks/test/browser
diff options
context:
space:
mode:
Diffstat (limited to 'preact/hooks/test/browser')
-rw-r--r--preact/hooks/test/browser/combinations.test.js301
-rw-r--r--preact/hooks/test/browser/errorBoundary.test.js92
-rw-r--r--preact/hooks/test/browser/hooks.options.test.js154
-rw-r--r--preact/hooks/test/browser/useCallback.test.js41
-rw-r--r--preact/hooks/test/browser/useContext.test.js351
-rw-r--r--preact/hooks/test/browser/useDebugValue.test.js71
-rw-r--r--preact/hooks/test/browser/useEffect.test.js373
-rw-r--r--preact/hooks/test/browser/useEffectAssertions.test.js142
-rw-r--r--preact/hooks/test/browser/useImperativeHandle.test.js182
-rw-r--r--preact/hooks/test/browser/useLayoutEffect.test.js326
-rw-r--r--preact/hooks/test/browser/useMemo.test.js125
-rw-r--r--preact/hooks/test/browser/useReducer.test.js214
-rw-r--r--preact/hooks/test/browser/useRef.test.js50
-rw-r--r--preact/hooks/test/browser/useState.test.js214
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('');
+ });
+});