Back to Repositories

Testing Null Placeholder Management in PreactJS

This test suite evaluates Preact’s handling of null placeholders and component state preservation, focusing on DOM manipulation and component lifecycle management. It verifies the framework’s ability to efficiently handle conditional rendering and maintain component states during updates.

Test Coverage Overview

The test suite covers comprehensive scenarios for null placeholder handling in Preact components:
  • Undefined value treatment as holes in component rendering
  • State preservation during null/boolean placeholder updates
  • Efficient replacement of self-updating null placeholders
  • Component lifecycle management with conditional rendering
  • Edge cases in parent component rerenders

Implementation Analysis

The testing approach utilizes component class definitions and functional components to validate placeholder behavior:
  • Uses createStatefulNullable pattern for component state management
  • Implements DOM operation logging to verify render efficiency
  • Employs component references for state manipulation
  • Tests both simple and complex component hierarchies

Technical Details

Testing infrastructure includes:
  • Jest/Minitest framework integration
  • Custom DOM helpers for scratch element setup
  • Operation logging utilities for DOM manipulation tracking
  • Component lifecycle hooks monitoring
  • Sinon for spy/stub functionality

Best Practices Demonstrated

The test suite exemplifies high-quality testing practices:
  • Isolated test cases with proper setup/teardown
  • Comprehensive state management verification
  • Explicit DOM operation tracking
  • Clear test case organization and naming
  • Thorough edge case coverage

preactjs/preact

test/browser/placeholders.test.js

            
import { createElement, Component, render, createRef, Fragment } from 'preact';
import { setupRerender } from 'preact/test-utils';
import { setupScratch, teardown } from '../_util/helpers';
import { logCall, clearLog, getLog } from '../_util/logCall';
import { div } from '../_util/dom';

/** @jsx createElement */

describe('null placeholders', () => {
	/** @type {HTMLDivElement} */
	let scratch;

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

	/** @type {string[]} */
	let ops;

	function createNullable(name) {
		return function Nullable(props) {
			return props.show ? name : null;
		};
	}

	/**
	 * @param {string} name
	 * @returns {[import('preact').ComponentClass, import('preact').RefObject<{ toggle(): void }>]}
	 */
	function createStatefulNullable(name) {
		let ref = createRef();
		class Nullable extends Component {
			constructor(props) {
				super(props);
				this.state = { show: props.initialShow || true };
				ref.current = this;
			}
			toggle() {
				this.setState({ show: !this.state.show });
			}
			componentDidUpdate() {
				ops.push(`Update ${name}`);
			}
			componentDidMount() {
				ops.push(`Mount ${name}`);
			}
			componentWillUnmount() {
				ops.push(`Unmount ${name}`);
			}
			render() {
				return this.state.show ? <div>{name}</div> : null;
			}
		}

		return [Nullable, ref];
	}

	let resetAppendChild;
	let resetInsertBefore;
	let resetRemoveChild;
	let resetRemove;

	before(() => {
		resetAppendChild = logCall(Element.prototype, 'appendChild');
		resetInsertBefore = logCall(Element.prototype, 'insertBefore');
		resetRemoveChild = logCall(Element.prototype, 'removeChild');
		resetRemove = logCall(Element.prototype, 'remove');
	});

	after(() => {
		resetAppendChild();
		resetInsertBefore();
		resetRemoveChild();
		resetRemove();
	});

	beforeEach(() => {
		scratch = setupScratch();
		rerender = setupRerender();
		ops = [];
	});

	afterEach(() => {
		teardown(scratch);
		clearLog();
	});

	it('should treat undefined as a hole', () => {
		let Bar = () => <div>bar</div>;

		function Foo(props) {
			let sibling;
			if (props.condition) {
				sibling = <Bar />;
			}

			return (
				<div>
					<div>Hello</div>
					{sibling}
				</div>
			);
		}

		render(<Foo condition />, scratch);
		expect(scratch.innerHTML).to.equal(
			'<div><div>Hello</div><div>bar</div></div>'
		);
		clearLog();

		render(<Foo />, scratch);
		expect(scratch.innerHTML).to.equal('<div><div>Hello</div></div>');
		expect(getLog()).to.deep.equal(['<div>bar.remove()']);
	});

	it('should preserve state of Components when using null or booleans as placeholders', () => {
		// Must be the same class for all children in <App /> for this test to be valid
		class Stateful extends Component {
			constructor(props) {
				super(props);
				this.state = { count: 0 };
			}
			increment() {
				this.setState({ count: this.state.count + 1 });
			}
			componentDidUpdate() {
				ops.push(`Update ${this.props.name}`);
			}
			componentDidMount() {
				ops.push(`Mount ${this.props.name}`);
			}
			componentWillUnmount() {
				ops.push(`Unmount ${this.props.name}`);
			}
			render() {
				return (
					<div>
						{this.props.name}: {this.state.count}
					</div>
				);
			}
		}

		const s1ref = createRef();
		const s2ref = createRef();
		const s3ref = createRef();

		function App({ first = null, second = false }) {
			return [first, second, <Stateful name="third" ref={s3ref} />];
		}

		// Mount third stateful - Initial render
		render(<App />, scratch);
		expect(scratch.innerHTML).to.equal('<div>third: 0</div>');
		expect(ops).to.deep.equal(['Mount third'], 'mount third');

		// Update third stateful
		ops = [];
		s3ref.current.increment();
		rerender();
		expect(scratch.innerHTML).to.equal('<div>third: 1</div>');
		expect(ops).to.deep.equal(['Update third'], 'update third');

		// Mount first stateful
		ops = [];
		render(<App first={<Stateful name="first" ref={s1ref} />} />, scratch);
		expect(scratch.innerHTML).to.equal(
			'<div>first: 0</div><div>third: 1</div>'
		);
		expect(ops).to.deep.equal(['Mount first', 'Update third'], 'mount first');

		// Update first stateful
		ops = [];
		s1ref.current.increment();
		s3ref.current.increment();
		rerender();
		expect(scratch.innerHTML).to.equal(
			'<div>first: 1</div><div>third: 2</div>'
		);
		expect(ops).to.deep.equal(['Update first', 'Update third'], 'update first');

		// Mount second stateful
		ops = [];
		render(
			<App
				first={<Stateful name="first" ref={s1ref} />}
				second={<Stateful name="second" ref={s2ref} />}
			/>,
			scratch
		);
		expect(scratch.innerHTML).to.equal(
			'<div>first: 1</div><div>second: 0</div><div>third: 2</div>'
		);
		expect(ops).to.deep.equal(
			['Update first', 'Mount second', 'Update third'],
			'mount second'
		);

		// Update second stateful
		ops = [];
		s1ref.current.increment();
		s2ref.current.increment();
		s3ref.current.increment();
		rerender();
		expect(scratch.innerHTML).to.equal(
			'<div>first: 2</div><div>second: 1</div><div>third: 3</div>'
		);
		expect(ops).to.deep.equal(
			['Update first', 'Update second', 'Update third'],
			'update second'
		);
	});

	it('should efficiently replace self-updating null placeholders', () => {
		// These Nullable components replace themselves with null without the parent re-rendering
		const [Nullable, ref] = createStatefulNullable('Nullable');
		const [Nullable2, ref2] = createStatefulNullable('Nullable2');
		function App() {
			return (
				<div>
					<div>1</div>
					<Nullable />
					<div>3</div>
					<Nullable2 />
				</div>
			);
		}

		render(<App />, scratch);
		expect(scratch.innerHTML).to.equal(
			div([div(1), div('Nullable'), div(3), div('Nullable2')])
		);

		clearLog();
		ref2.current.toggle();
		ref.current.toggle();
		rerender();
		expect(scratch.innerHTML).to.equal(div([div(1), div(3)]));
		expect(getLog()).to.deep.equal([
			'<div>Nullable2.remove()',
			'<div>Nullable.remove()'
		]);

		clearLog();
		ref2.current.toggle();
		ref.current.toggle();
		rerender();
		expect(scratch.innerHTML).to.equal(
			div([div(1), div('Nullable'), div(3), div('Nullable2')])
		);
		expect(getLog()).to.deep.equal([
			'<div>.appendChild(#text)',
			'<div>13.appendChild(<div>Nullable2)',
			'<div>.appendChild(#text)',
			'<div>13Nullable2.insertBefore(<div>Nullable, <div>3)'
		]);
	});

	// See preactjs/preact#2350
	it('should efficiently replace null placeholders in parent rerenders (#2350)', () => {
		// This Nullable only changes when it's parent rerenders
		const Nullable1 = createNullable('Nullable 1');
		const Nullable2 = createNullable('Nullable 2');

		/** @type {() => void} */
		let toggle;
		class App extends Component {
			constructor(props) {
				super(props);
				this.state = { show: false };
				toggle = () => this.setState({ show: !this.state.show });
			}
			render() {
				return (
					<div>
						<div>{this.state.show.toString()}</div>
						<Nullable1 show={this.state.show} />
						<div>the middle</div>
						<Nullable2 show={this.state.show} />
					</div>
				);
			}
		}

		render(<App />, scratch);
		expect(scratch.innerHTML).to.equal(div([div('false'), div('the middle')]));

		clearLog();
		toggle();
		rerender();
		expect(scratch.innerHTML).to.equal(
			div([div('true'), 'Nullable 1', div('the middle'), 'Nullable 2'])
		);
		expect(getLog()).to.deep.equal([
			'<div>truethe middle.insertBefore(#text, <div>the middle)',
			'<div>trueNullable 1the middle.appendChild(#text)'
		]);

		clearLog();
		toggle();
		rerender();
		expect(scratch.innerHTML).to.equal(div([div('false'), div('the middle')]));
		expect(getLog()).to.deep.equal([
			'#text.remove()',
			// '<div>falsethe middleNullable 2.appendChild(<div>the middle)',
			'#text.remove()'
		]);
	});

	it('when set through the children prop (#4074)', () => {
		/** @type {(state: { value: boolean }) => void} */
		let setState;
		const Iframe = () => {
			// Using a div here to make debugging tests in devtools a little less
			// noisy. The dom ops still assert that the iframe isn't moved.
			//
			// return <iframe src="https://codesandbox.io/s/runtime-silence-no4zx" />;
			return <div>Iframe</div>;
		};

		const Test2 = () => <div>Test2</div>;
		const Test3 = () => <div>Test3</div>;

		class App extends Component {
			constructor(props) {
				super(props);
				this.state = { value: true };
				setState = this.setState.bind(this);
			}

			render(props, state) {
				return (
					<div>
						<Test2 />
						{state.value && <Test3 />}
						{props.children}
					</div>
				);
			}
		}

		render(
			<App>
				<Iframe />
			</App>,
			scratch
		);

		expect(scratch.innerHTML).to.equal(
			'<div><div>Test2</div><div>Test3</div><div>Iframe</div></div>'
		);
		clearLog();
		setState({ value: false });
		rerender();

		expect(scratch.innerHTML).to.equal(
			'<div><div>Test2</div><div>Iframe</div></div>'
		);
		expect(getLog()).to.deep.equal(['<div>Test3.remove()']);
	});

	it('when set through the children prop when removing a Fragment with multiple DOM children (#4074)', () => {
		/** @type {(state: { value: boolean }) => void} */
		let setState;
		const Iframe = () => {
			// Using a div here to make debugging tests in devtools a little less
			// noisy. The dom ops still assert that the iframe isn't moved.
			//
			// return <iframe src="https://codesandbox.io/s/runtime-silence-no4zx" />;
			return <div>Iframe</div>;
		};

		const Test2 = () => <div>Test2</div>;
		const Test34 = () => (
			<Fragment>
				<div>Test3</div>
				<div>Test4</div>
			</Fragment>
		);

		class App extends Component {
			constructor(props) {
				super(props);
				this.state = { value: true };
				setState = this.setState.bind(this);
			}

			render(props, state) {
				return (
					<div>
						<Test2 />
						{state.value && <Test34 />}
						{props.children}
					</div>
				);
			}
		}

		render(
			<App>
				<Iframe />
			</App>,
			scratch
		);

		expect(scratch.innerHTML).to.equal(
			'<div><div>Test2</div><div>Test3</div><div>Test4</div><div>Iframe</div></div>'
		);
		clearLog();
		setState({ value: false });
		rerender();

		expect(scratch.innerHTML).to.equal(
			'<div><div>Test2</div><div>Iframe</div></div>'
		);
		expect(getLog()).to.deep.equal([
			'<div>Test3.remove()',
			'<div>Test4.remove()'
		]);
	});

	it('should only call unmount once when removing placeholders (#4104)', () => {
		/** @type {(state: { value: boolean }) => void} */
		let setState;
		const Iframe = () => {
			// Using a div here to make debugging tests in devtools a little less
			// noisy. The dom ops still assert that the iframe isn't moved.
			//
			// return <iframe src="https://codesandbox.io/s/runtime-silence-no4zx" />;
			return <div>Iframe</div>;
		};

		const Test2 = () => <div>Test2</div>;

		const ref = sinon.spy();

		class App extends Component {
			constructor(props) {
				super(props);
				this.state = { value: true };
				setState = this.setState.bind(this);
			}

			render(props, state) {
				return (
					<div>
						<Test2 />
						{state.value && <div ref={ref}>Test3</div>}
						{props.children}
					</div>
				);
			}
		}

		render(
			<App>
				<Iframe />
			</App>,
			scratch
		);

		expect(scratch.innerHTML).to.equal(
			'<div><div>Test2</div><div>Test3</div><div>Iframe</div></div>'
		);
		expect(ref).to.have.been.calledOnce;

		ref.resetHistory();
		clearLog();
		setState({ value: false });
		rerender();

		expect(scratch.innerHTML).to.equal(
			'<div><div>Test2</div><div>Iframe</div></div>'
		);
		expect(getLog()).to.deep.equal(['<div>Test3.remove()']);
		expect(ref).to.have.been.calledOnce;
	});
});