Back to Repositories

Testing Hydration Implementation in PreactJS

This test suite validates the hydration functionality in Preact, focusing on DOM reuse and event handling during server-side rendering (SSR) to client-side hydration. The tests ensure proper component mounting, attribute preservation, and event listener attachment during the hydration process.

Test Coverage Overview

The test suite provides comprehensive coverage of Preact’s hydration functionality:
  • DOM reuse and preservation during hydration
  • Event handler attachment and proper execution
  • Fragment handling and nested component hydration
  • Edge cases including comment node handling and error boundaries
  • Form element behavior with defaultValue and defaultChecked

Implementation Analysis

The testing approach utilizes Jest and custom utilities to verify hydration behavior. It implements spy functions to track DOM operations and event handling, while using a scratch element fixture for DOM manipulation. The tests leverage Preact-specific features like Fragments and Components to validate complex rendering scenarios.

Technical Details

Key technical components include:
  • Jest test framework with Sinon for spies and mocks
  • Custom DOM helpers and logging utilities
  • Element prototype method tracking
  • Event simulation and dispatch testing
  • Attribute manipulation verification

Best Practices Demonstrated

The test suite exemplifies strong testing practices through systematic validation of hydration scenarios. It includes proper test isolation, cleanup between tests, comprehensive edge case coverage, and clear test organization. The implementation demonstrates effective use of beforeEach/afterEach hooks and proper error boundary testing.

preactjs/preact

test/browser/hydrate.test.js

            
import { createElement, hydrate, Fragment, Component } from 'preact';
import { setupRerender } from 'preact/test-utils';
import {
	setupScratch,
	teardown,
	sortAttributes,
	serializeHtml,
	spyOnElementAttributes,
	createEvent
} from '../_util/helpers';
import { ul, li, div } from '../_util/dom';
import { logCall, clearLog, getLog } from '../_util/logCall';

/** @jsx createElement */

describe('hydrate()', () => {
	/** @type {HTMLElement} */
	let scratch;
	let attributesSpy;

	const List = ({ children }) => <ul>{children}</ul>;
	const ListItem = ({ children, onClick = null }) => (
		<li onClick={onClick}>{children}</li>
	);

	let resetAppendChild;
	let resetInsertBefore;
	let resetRemoveChild;
	let resetRemove;
	let resetSetAttribute;
	let resetRemoveAttribute;
	let rerender;

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

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

	beforeEach(() => {
		rerender = setupRerender();
		scratch = setupScratch();
		attributesSpy = spyOnElementAttributes();
		clearLog();
	});

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

	// Test for preactjs/preact#4340
	it('should respect defaultValue in hydrate', () => {
		scratch.innerHTML = '<input value="foo">';
		hydrate(<input defaultValue="foo" />, scratch);
		expect(scratch.firstChild.value).to.equal('foo');
	});

	it('should respect defaultChecked in hydrate', () => {
		scratch.innerHTML = '<input checked="true">';
		hydrate(<input defaultChecked />, scratch);
		expect(scratch.firstChild.checked).to.equal(true);
	});

	it('should reuse existing DOM', () => {
		const onClickSpy = sinon.spy();
		const html = ul([li('1'), li('2'), li('3')]);

		scratch.innerHTML = html;
		clearLog();

		hydrate(
			<ul>
				<li>1</li>
				<li>2</li>
				<li onClick={onClickSpy}>3</li>
			</ul>,
			scratch
		);

		expect(scratch.innerHTML).to.equal(html);
		expect(getLog()).to.deep.equal([]);
		expect(onClickSpy).not.to.have.been.called;

		scratch.querySelector('li:last-child').dispatchEvent(createEvent('click'));

		expect(onClickSpy).to.have.been.called.calledOnce;
	});

	it('should skip comment nodes between dom nodes', () => {
		scratch.innerHTML = '<p><i>0</i><!-- c --><b>1</b></p>';
		hydrate(
			<p>
				<i>0</i>
				<b>1</b>
			</p>,
			scratch
		);
		expect(scratch.innerHTML).to.equal('<p><i>0</i><b>1</b></p>');
		expect(getLog()).to.deep.equal(['Comment.remove()']);
	});

	it('should reuse existing DOM when given components', () => {
		const onClickSpy = sinon.spy();
		const html = ul([li('1'), li('2'), li('3')]);

		scratch.innerHTML = html;
		clearLog();

		hydrate(
			<List>
				<ListItem>1</ListItem>
				<ListItem>2</ListItem>
				<ListItem onClick={onClickSpy}>3</ListItem>
			</List>,
			scratch
		);

		expect(scratch.innerHTML).to.equal(html);
		expect(getLog()).to.deep.equal([]);
		expect(onClickSpy).not.to.have.been.called;

		scratch.querySelector('li:last-child').dispatchEvent(createEvent('click'));

		expect(onClickSpy).to.have.been.called.calledOnce;
	});

	it('should properly set event handlers to existing DOM when given components', () => {
		const proto = Element.prototype;
		sinon.spy(proto, 'addEventListener');

		const clickHandlers = [sinon.spy(), sinon.spy(), sinon.spy()];

		const html = ul([li('1'), li('2'), li('3')]);

		scratch.innerHTML = html;
		clearLog();

		hydrate(
			<List>
				<ListItem onClick={clickHandlers[0]}>1</ListItem>
				<ListItem onClick={clickHandlers[1]}>2</ListItem>
				<ListItem onClick={clickHandlers[2]}>3</ListItem>
			</List>,
			scratch
		);

		expect(scratch.innerHTML).to.equal(html);
		expect(getLog()).to.deep.equal([]);
		expect(proto.addEventListener).to.have.been.calledThrice;
		expect(clickHandlers[2]).not.to.have.been.called;

		scratch.querySelector('li:last-child').dispatchEvent(createEvent('click'));
		expect(clickHandlers[2]).to.have.been.calledOnce;
	});

	it('should add missing nodes to existing DOM when hydrating', () => {
		const html = ul([li('1')]);

		scratch.innerHTML = html;
		clearLog();

		hydrate(
			<List>
				<ListItem>1</ListItem>
				<ListItem>2</ListItem>
				<ListItem>3</ListItem>
			</List>,
			scratch
		);

		expect(scratch.innerHTML).to.equal(ul([li('1'), li('2'), li('3')]));
		expect(getLog()).to.deep.equal([
			'<li>.appendChild(#text)',
			'<ul>1.appendChild(<li>2)',
			'<li>.appendChild(#text)',
			'<ul>12.appendChild(<li>3)'
		]);
	});

	it('should remove extra nodes from existing DOM when hydrating', () => {
		const html = ul([li('1'), li('2'), li('3'), li('4')]);

		scratch.innerHTML = html;
		clearLog();

		hydrate(
			<List>
				<ListItem>1</ListItem>
				<ListItem>2</ListItem>
				<ListItem>3</ListItem>
			</List>,
			scratch
		);

		expect(scratch.innerHTML).to.equal(ul([li('1'), li('2'), li('3')]));
		expect(getLog()).to.deep.equal(['<li>4.remove()']);
	});

	it('should not update attributes on existing DOM', () => {
		scratch.innerHTML =
			'<div><span before-hydrate="test" same-value="foo" different-value="a">Test</span></div>';
		let vnode = (
			<div>
				<span same-value="foo" different-value="b" new-value="c">
					Test
				</span>
			</div>
		);

		clearLog();
		hydrate(vnode, scratch);

		// IE11 doesn't support spying on Element.prototype
		if (!/Trident/.test(navigator.userAgent)) {
			expect(attributesSpy.get).to.not.have.been.called;
		}

		expect(serializeHtml(scratch)).to.equal(
			sortAttributes(
				'<div><span before-hydrate="test" different-value="a" same-value="foo">Test</span></div>'
			)
		);
		expect(getLog()).to.deep.equal([]);
	});

	it('should update class attribute via className prop', () => {
		scratch.innerHTML = '<div class="foo">bar</div>';
		hydrate(<div className="foo">bar</div>, scratch);
		expect(scratch.innerHTML).to.equal('<div class="foo">bar</div>');
	});

	it('should correctly hydrate with Fragments', () => {
		const html = ul([li('1'), li('2'), li('3'), li('4')]);

		scratch.innerHTML = html;
		clearLog();

		const clickHandlers = [sinon.spy(), sinon.spy(), sinon.spy(), sinon.spy()];

		hydrate(
			<List>
				<ListItem onClick={clickHandlers[0]}>1</ListItem>
				<Fragment>
					<ListItem onClick={clickHandlers[1]}>2</ListItem>
					<ListItem onClick={clickHandlers[2]}>3</ListItem>
				</Fragment>
				<ListItem onClick={clickHandlers[3]}>4</ListItem>
			</List>,
			scratch
		);

		expect(scratch.innerHTML).to.equal(html);
		expect(getLog()).to.deep.equal([]);
		expect(clickHandlers[2]).not.to.have.been.called;

		scratch
			.querySelector('li:nth-child(3)')
			.dispatchEvent(createEvent('click'));

		expect(clickHandlers[2]).to.have.been.called.calledOnce;
	});

	it('should correctly hydrate root Fragments', () => {
		const html = [
			ul([li('1'), li('2'), li('3'), li('4')]),
			div('sibling')
		].join('');

		scratch.innerHTML = html;
		clearLog();

		const clickHandlers = [
			sinon.spy(),
			sinon.spy(),
			sinon.spy(),
			sinon.spy(),
			sinon.spy()
		];

		hydrate(
			<Fragment>
				<List>
					<Fragment>
						<ListItem onClick={clickHandlers[0]}>1</ListItem>
						<ListItem onClick={clickHandlers[1]}>2</ListItem>
					</Fragment>
					<ListItem onClick={clickHandlers[2]}>3</ListItem>
					<ListItem onClick={clickHandlers[3]}>4</ListItem>
				</List>
				<div onClick={clickHandlers[4]}>sibling</div>
			</Fragment>,
			scratch
		);

		expect(scratch.innerHTML).to.equal(html);
		expect(getLog()).to.deep.equal([]);
		expect(clickHandlers[2]).not.to.have.been.called;

		scratch
			.querySelector('li:nth-child(3)')
			.dispatchEvent(createEvent('click'));

		expect(clickHandlers[2]).to.have.been.calledOnce;
		expect(clickHandlers[4]).not.to.have.been.called;

		scratch.querySelector('div').dispatchEvent(createEvent('click'));

		expect(clickHandlers[2]).to.have.been.calledOnce;
		expect(clickHandlers[4]).to.have.been.calledOnce;
	});

	it('should override incorrect pre-existing DOM with VNodes passed into render', () => {
		const initialHtml = [
			div('sibling'),
			ul([li('1'), li('4'), li('3'), li('2')])
		].join('');

		scratch.innerHTML = initialHtml;
		clearLog();

		hydrate(
			<Fragment>
				<List>
					<Fragment>
						<ListItem>1</ListItem>
						<ListItem>2</ListItem>
					</Fragment>
					<ListItem>3</ListItem>
					<ListItem>4</ListItem>
				</List>
				<div>sibling</div>
			</Fragment>,
			scratch
		);

		const finalHtml = [
			ul([li('1'), li('2'), li('3'), li('4')]),
			div('sibling')
		].join('');

		expect(scratch.innerHTML).to.equal(finalHtml);
		expect(getLog()).to.deep.equal([
			'<div>sibling1234.insertBefore(<ul>1234, <div>sibling)'
		]);
	});

	it('should not merge attributes with node created by the DOM', () => {
		const html = htmlString => {
			const div = document.createElement('div');
			div.innerHTML = htmlString;
			return div.firstChild;
		};

		// prettier-ignore
		const DOMElement = html`<div><a foo="bar"></a></div>`;
		scratch.appendChild(DOMElement);

		const preactElement = (
			<div>
				<a />
			</div>
		);

		hydrate(preactElement, scratch);
		// IE11 doesn't support spies on built-in prototypes
		if (!/Trident/.test(navigator.userAgent)) {
			expect(attributesSpy.get).to.not.have.been.called;
		}
		expect(scratch).to.have.property(
			'innerHTML',
			'<div><a foo="bar"></a></div>'
		);
	});

	it('should attach event handlers', () => {
		let spy = sinon.spy();
		scratch.innerHTML = '<span>Test</span>';
		let vnode = <span onClick={spy}>Test</span>;

		hydrate(vnode, scratch);

		scratch.firstChild.click();
		expect(spy).to.be.calledOnce;
	});

	// #2237
	it('should not redundantly add text nodes', () => {
		scratch.innerHTML = '<div id="test"><p>hello bar</p></div>';
		const element = document.getElementById('test');
		const Component = props => <p>hello {props.foo}</p>;

		hydrate(<Component foo="bar" />, element);
		expect(element.innerHTML).to.equal('<p>hello bar</p>');
	});

	it('should not remove values', () => {
		scratch.innerHTML =
			'<select><option value="0">Zero</option><option selected value="2">Two</option></select>';
		const App = () => {
			const options = [
				{
					value: '0',
					label: 'Zero'
				},
				{
					value: '2',
					label: 'Two'
				}
			];

			return (
				<select value="2">
					{options.map(({ disabled, label, value }) => (
						<option key={label} disabled={disabled} value={value}>
							{label}
						</option>
					))}
				</select>
			);
		};

		hydrate(<App />, scratch);
		expect(sortAttributes(scratch.innerHTML)).to.equal(
			sortAttributes(
				'<select><option value="0">Zero</option><option selected="" value="2">Two</option></select>'
			)
		);
	});

	it('should deopt for trees introduced in hydrate (append)', () => {
		scratch.innerHTML = '<div id="test"><p class="hi">hello bar</p></div>';
		const Component = props => <p class="hi">hello {props.foo}</p>;
		const element = document.getElementById('test');
		hydrate(
			<Fragment>
				<Component foo="bar" />
				<Component foo="baz" />
			</Fragment>,
			element
		);
		expect(element.innerHTML).to.equal(
			'<p class="hi">hello bar</p><p class="hi">hello baz</p>'
		);
	});

	it('should deopt for trees introduced in hydrate (insert before)', () => {
		scratch.innerHTML = '<div id="test"><p class="hi">hello bar</p></div>';
		const Component = props => <p class="hi">hello {props.foo}</p>;
		const element = document.getElementById('test');
		hydrate(
			<Fragment>
				<Component foo="baz" />
				<Component foo="bar" />
			</Fragment>,
			element
		);
		expect(element.innerHTML).to.equal(
			'<p class="hi">hello baz</p><p class="hi">hello bar</p>'
		);
	});

	it('should skip comment nodes', () => {
		scratch.innerHTML = '<p>hello <!-- c -->foo</p>';
		hydrate(<p>hello {'foo'}</p>, scratch);
		expect(scratch.innerHTML).to.equal('<p>hello foo</p>');
		expect(getLog()).to.deep.equal(['Comment.remove()']);
	});

	it('should skip over multiple comment nodes', () => {
		scratch.innerHTML = '<p>hello <!-- a --><!-- b -->foo</p>';
		hydrate(<p>hello {'foo'}</p>, scratch);
		expect(scratch.innerHTML).to.equal('<p>hello foo</p>');
		expect(getLog()).to.deep.equal(['Comment.remove()', 'Comment.remove()']);
	});

	it('should work with error boundaries', () => {
		scratch.innerHTML = '<div>Hello, World!</div>';
		class Root extends Component {
			constructor() {
				super();
				this.state = {};
			}

			componentDidCatch(error) {
				this.setState({ error });
			}

			render() {
				if (!this.state.error) {
					return <App />;
				} else {
					return <div>Error!</div>;
				}
			}
		}

		function App() {
			throw Error();
			return createElement('div', {}, 'Hello, World!');
		}

		hydrate(<Root />, scratch);
		rerender();
		expect(scratch.innerHTML).to.equal('<div>Error!</div>');
	});
});