Back to Repositories

Testing Hook Combinations and Effect Ordering in Preact

This test suite validates the integration and combination of various Preact hooks, focusing on complex interactions between useState, useEffect, useLayoutEffect, and other hook combinations. It ensures proper hook behavior in nested components and verifies correct execution order of effects and state updates.

Test Coverage Overview

The test suite provides comprehensive coverage of hook combinations and interactions in Preact:
  • State management across nested components using useState
  • Synchronous and asynchronous rendering with useEffect and useLayoutEffect
  • Complex state updates using useReducer
  • Ref handling and lifecycle timing verification
  • Context updates and state synchronization

Implementation Analysis

The testing approach employs Jest’s describe/it pattern with extensive use of act() for managing component updates. Tests verify hook behavior through component rendering, state mutations, and effect execution timing. The implementation focuses on real-world scenarios including component reuse, ref management, and context updates.

Technical Details

Key technical components include:
  • Jest test framework with Sinon for spies and mocks
  • Custom test utilities: setupScratch, teardown, setupRerender
  • Preact test-utils for component lifecycle management
  • Custom effect scheduling utilities for async testing

Best Practices Demonstrated

The test suite exemplifies testing best practices through:
  • Proper test isolation with beforeEach/afterEach cleanup
  • Comprehensive edge case coverage for hook interactions
  • Explicit validation of component rendering and effect timing
  • Clear separation of synchronous and asynchronous behavior testing

preactjs/preact

hooks/test/browser/combinations.test.js

            
import { setupRerender, act } from 'preact/test-utils';
import { createElement, render, Component, createContext } from 'preact';
import { setupScratch, teardown } from '../../../test/_util/helpers';
import {
	useState,
	useReducer,
	useEffect,
	useLayoutEffect,
	useRef,
	useMemo,
	useContext
} 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>'
		]);
	});

	// TODO: I actually think this is an acceptable failure, because we update child first and then parent
	// the effects are out of order
	it.skip('should run effects child-first even for children separated by memoization', () => {
		let ops = [];

		/** @type {() => void} */
		let updateChild;
		/** @type {() => void} */
		let updateParent;

		function Child() {
			const [, setCount] = useState(0);
			updateChild = () => setCount(c => c + 1);
			useEffect(() => {
				ops.push('child effect');
			});
			return <div>Child</div>;
		}

		function Parent() {
			const [, setCount] = useState(0);
			updateParent = () => setCount(c => c + 1);
			const memoedChild = useMemo(() => <Child />, []);
			useEffect(() => {
				ops.push('parent effect');
			});
			return (
				<div>
					<div>Parent</div>
					{memoedChild}
				</div>
			);
		}

		act(() => render(<Parent />, scratch));
		expect(ops).to.deep.equal(['child effect', 'parent effect']);

		ops = [];
		updateChild();
		updateParent();
		act(() => rerender());

		expect(ops).to.deep.equal(['child effect', 'parent effect']);
	});

	it('should not block hook updates when context updates are enqueued', () => {
		const Ctx = createContext({
			value: 0,
			setValue: /** @type {*} */ () => {}
		});

		let triggerSubmit = () => {};
		function Child() {
			const ctx = useContext(Ctx);
			const [shouldSubmit, setShouldSubmit] = useState(false);
			triggerSubmit = () => setShouldSubmit(true);

			useEffect(() => {
				if (shouldSubmit) {
					// Update parent state and child state at the same time
					ctx.setValue(v => v + 1);
					setShouldSubmit(false);
				}
			}, [shouldSubmit]);

			return <p>{ctx.value}</p>;
		}

		function App() {
			const [value, setValue] = useState(0);
			const ctx = useMemo(() => {
				return { value, setValue };
			}, [value]);
			return (
				<Ctx.Provider value={ctx}>
					<Child />
				</Ctx.Provider>
			);
		}

		act(() => {
			render(<App />, scratch);
		});

		expect(scratch.textContent).to.equal('0');

		act(() => {
			triggerSubmit();
		});
		expect(scratch.textContent).to.equal('1');

		// This is where the update wasn't applied
		act(() => {
			triggerSubmit();
		});
		expect(scratch.textContent).to.equal('2');
	});

	it('parent and child refs should be set before all effects', () => {
		const anchorId = 'anchor';
		const tooltipId = 'tooltip';
		const effectLog = [];

		let useRef2 = sinon.spy(init => {
			const realRef = useRef(init);
			const ref = useRef(init);
			Object.defineProperty(ref, 'current', {
				get: () => realRef.current,
				set: value => {
					realRef.current = value;
					effectLog.push('set ref ' + value?.tagName);
				}
			});
			return ref;
		});

		function Tooltip({ anchorRef, children }) {
			// For example, used to manually position the tooltip
			const tooltipRef = useRef2(null);

			useLayoutEffect(() => {
				expect(anchorRef.current?.id).to.equal(anchorId);
				expect(tooltipRef.current?.id).to.equal(tooltipId);
				effectLog.push('tooltip layout effect');
			}, [anchorRef, tooltipRef]);
			useEffect(() => {
				expect(anchorRef.current?.id).to.equal(anchorId);
				expect(tooltipRef.current?.id).to.equal(tooltipId);
				effectLog.push('tooltip effect');
			}, [anchorRef, tooltipRef]);

			return (
				<div class="tooltip-wrapper">
					<div id={tooltipId} ref={tooltipRef}>
						{children}
					</div>
				</div>
			);
		}

		function App() {
			// For example, used to define what element to anchor the tooltip to
			const anchorRef = useRef2(null);

			useLayoutEffect(() => {
				expect(anchorRef.current?.id).to.equal(anchorId);
				effectLog.push('anchor layout effect');
			}, [anchorRef]);
			useEffect(() => {
				expect(anchorRef.current?.id).to.equal(anchorId);
				effectLog.push('anchor effect');
			}, [anchorRef]);

			return (
				<div>
					<p id={anchorId} ref={anchorRef}>
						More info
					</p>
					<Tooltip anchorRef={anchorRef}>a tooltip</Tooltip>
				</div>
			);
		}

		act(() => {
			render(<App />, scratch);
		});

		expect(effectLog).to.deep.equal([
			'set ref P',
			'set ref DIV',
			'tooltip layout effect',
			'anchor layout effect',
			'tooltip effect',
			'anchor effect'
		]);
	});

	it('should not loop infinitely', () => {
		const actions = [];
		let toggle;
		function App() {
			const [value, setValue] = useState(false);

			const data = useMemo(() => {
				actions.push('memo');
				return {};
			}, [value]);

			const [prevData, setPreviousData] = useState(data);
			if (prevData !== data) {
				setPreviousData(data);
			}

			actions.push('render');
			toggle = () => setValue(!value);
			return <div>Value: {JSON.stringify(value)}</div>;
		}

		act(() => {
			render(<App />, scratch);
		});
		expect(actions).to.deep.equal(['memo', 'render']);
		expect(scratch.innerHTML).to.deep.equal('<div>Value: false</div>');

		act(() => {
			toggle();
		});
		expect(actions).to.deep.equal([
			'memo',
			'render',
			'memo',
			'render',
			'render'
		]);
		expect(scratch.innerHTML).to.deep.equal('<div>Value: true</div>');
	});
});