Back to Repositories

Testing getDerivedStateFromProps Lifecycle Implementation in Preact

This test suite validates the getDerivedStateFromProps lifecycle method in Preact components, covering state initialization, updates, and interactions with other lifecycle methods. The tests ensure proper state derivation from props and maintain component state integrity.

Test Coverage Overview

The test suite provides comprehensive coverage of getDerivedStateFromProps functionality in Preact components.

Key areas tested include:
  • Initial state setting from props
  • State updates based on prop changes
  • State updates based on internal state changes
  • Null/undefined return handling
  • Interaction with deprecated lifecycle methods
  • State mutation prevention

Implementation Analysis

The testing approach uses Jest/Minitest frameworks with sinon for spying on method calls. Tests utilize component class definitions with various getDerivedStateFromProps implementations to verify state derivation behavior.

Key patterns include component mounting, prop updates, state changes through setState, and verification of state immutability.

Technical Details

Testing tools and setup:
  • Jest/Minitest test framework
  • Sinon for method spying
  • Custom scratch element setup for DOM rendering
  • Preact test-utils for rerender functionality
  • Component lifecycle hooks testing utilities

Best Practices Demonstrated

The test suite exemplifies high-quality testing practices for component lifecycle methods.

Notable practices include:
  • Isolated test cases for specific behaviors
  • Proper test setup and teardown
  • Comprehensive edge case coverage
  • State immutability verification
  • Clear test case organization and naming

preactjs/preact

test/browser/lifecycles/getDerivedStateFromProps.test.js

            
import { setupRerender } from 'preact/test-utils';
import { createElement, render, Component } from 'preact';
import { setupScratch, teardown } from '../../_util/helpers';

/** @jsx createElement */

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

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

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

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

	describe('static getDerivedStateFromProps', () => {
		it('should set initial state with value returned from getDerivedStateFromProps', () => {
			class Foo extends Component {
				static getDerivedStateFromProps(props) {
					return {
						foo: props.foo,
						bar: 'bar'
					};
				}
				render() {
					return <div className={`${this.state.foo} ${this.state.bar}`} />;
				}
			}

			render(<Foo foo="foo" />, scratch);
			expect(scratch.firstChild.className).to.be.equal('foo bar');
		});

		it('should update initial state with value returned from getDerivedStateFromProps', () => {
			class Foo extends Component {
				constructor(props, context) {
					super(props, context);
					this.state = {
						foo: 'foo',
						bar: 'bar'
					};
				}
				static getDerivedStateFromProps(props, state) {
					return {
						foo: `not-${state.foo}`
					};
				}
				render() {
					return <div className={`${this.state.foo} ${this.state.bar}`} />;
				}
			}

			render(<Foo />, scratch);
			expect(scratch.firstChild.className).to.equal('not-foo bar');
		});

		it("should update the instance's state with the value returned from getDerivedStateFromProps when props change", () => {
			class Foo extends Component {
				constructor(props, context) {
					super(props, context);
					this.state = {
						value: 'initial'
					};
				}
				static getDerivedStateFromProps(props) {
					if (props.update) {
						return {
							value: 'updated'
						};
					}

					return null;
				}
				componentDidMount() {}
				componentDidUpdate() {}
				render() {
					return <div className={this.state.value} />;
				}
			}

			sinon.spy(Foo, 'getDerivedStateFromProps');
			sinon.spy(Foo.prototype, 'componentDidMount');
			sinon.spy(Foo.prototype, 'componentDidUpdate');

			render(<Foo update={false} />, scratch);
			expect(scratch.firstChild.className).to.equal('initial');
			expect(Foo.getDerivedStateFromProps).to.have.callCount(1);
			expect(Foo.prototype.componentDidMount).to.have.callCount(1); // verify mount occurred
			expect(Foo.prototype.componentDidUpdate).to.have.callCount(0);

			render(<Foo update />, scratch);
			expect(scratch.firstChild.className).to.equal('updated');
			expect(Foo.getDerivedStateFromProps).to.have.callCount(2);
			expect(Foo.prototype.componentDidMount).to.have.callCount(1);
			expect(Foo.prototype.componentDidUpdate).to.have.callCount(1); // verify update occurred
		});

		it("should update the instance's state with the value returned from getDerivedStateFromProps when state changes", () => {
			class Foo extends Component {
				constructor(props, context) {
					super(props, context);
					this.state = {
						value: 'initial'
					};
				}
				static getDerivedStateFromProps(props, state) {
					// Don't change state for call that happens after the constructor
					if (state.value === 'initial') {
						return null;
					}

					return {
						value: state.value + ' derived'
					};
				}
				componentDidMount() {
					// eslint-disable-next-line react/no-did-mount-set-state
					this.setState({ value: 'updated' });
				}
				render() {
					return <div className={this.state.value} />;
				}
			}

			sinon.spy(Foo, 'getDerivedStateFromProps');

			render(<Foo />, scratch);
			expect(scratch.firstChild.className).to.equal('initial');
			expect(Foo.getDerivedStateFromProps).to.have.been.calledOnce;

			rerender(); // call rerender to handle cDM setState call
			expect(scratch.firstChild.className).to.equal('updated derived');
			expect(Foo.getDerivedStateFromProps).to.have.been.calledTwice;
		});

		it('should NOT modify state if null is returned', () => {
			class Foo extends Component {
				constructor(props, context) {
					super(props, context);
					this.state = {
						foo: 'foo',
						bar: 'bar'
					};
				}
				static getDerivedStateFromProps() {
					return null;
				}
				render() {
					return <div className={`${this.state.foo} ${this.state.bar}`} />;
				}
			}

			sinon.spy(Foo, 'getDerivedStateFromProps');

			render(<Foo />, scratch);
			expect(scratch.firstChild.className).to.equal('foo bar');
			expect(Foo.getDerivedStateFromProps).to.have.been.called;
		});

		// NOTE: Difference from React
		// React v16.3.2 warns if undefined if returned from getDerivedStateFromProps
		it('should NOT modify state if undefined is returned', () => {
			class Foo extends Component {
				constructor(props, context) {
					super(props, context);
					this.state = {
						foo: 'foo',
						bar: 'bar'
					};
				}
				static getDerivedStateFromProps() {}
				render() {
					return <div className={`${this.state.foo} ${this.state.bar}`} />;
				}
			}

			sinon.spy(Foo, 'getDerivedStateFromProps');

			render(<Foo />, scratch);
			expect(scratch.firstChild.className).to.equal('foo bar');
			expect(Foo.getDerivedStateFromProps).to.have.been.called;
		});

		it('should NOT invoke deprecated lifecycles (cWM/cWRP) if new static gDSFP is present', () => {
			class Foo extends Component {
				static getDerivedStateFromProps() {}
				componentWillMount() {}
				componentWillReceiveProps() {}
				render() {
					return <div />;
				}
			}

			sinon.spy(Foo, 'getDerivedStateFromProps');
			sinon.spy(Foo.prototype, 'componentWillMount');
			sinon.spy(Foo.prototype, 'componentWillReceiveProps');

			render(<Foo />, scratch);
			expect(Foo.getDerivedStateFromProps).to.have.been.called;
			expect(Foo.prototype.componentWillMount).to.not.have.been.called;
			expect(Foo.prototype.componentWillReceiveProps).to.not.have.been.called;
		});

		it('is not called if neither state nor props have changed', () => {
			let logs = [];
			let childRef;

			class Parent extends Component {
				constructor(props) {
					super(props);
					this.state = { parentRenders: 0 };
				}

				static getDerivedStateFromProps(props, state) {
					logs.push('parent getDerivedStateFromProps');
					return state.parentRenders + 1;
				}

				render() {
					logs.push('parent render');
					return <Child parentRenders={this.state.parentRenders} />;
				}
			}

			class Child extends Component {
				constructor(props) {
					super(props);
					childRef = this;
				}
				render() {
					logs.push('child render');
					return this.props.parentRenders;
				}
			}

			render(<Parent />, scratch);
			expect(logs).to.deep.equal([
				'parent getDerivedStateFromProps',
				'parent render',
				'child render'
			]);

			logs = [];
			childRef.setState({});
			rerender();
			expect(logs).to.deep.equal(['child render']);
		});

		// TODO: Investigate this test:
		// [should not override state with stale values if prevState is spread within getDerivedStateFromProps](https://github.com/facebook/react/blob/25dda90c1ecb0c662ab06e2c80c1ee31e0ae9d36/packages/react-dom/src/__tests__/ReactComponentLifeCycle-test.js#L1035)

		it('should be passed next props and state', () => {
			/** @type {() => void} */
			let updateState;

			let propsArg;
			let stateArg;

			class Foo extends Component {
				constructor(props) {
					super(props);
					this.state = {
						value: 0
					};
					updateState = () =>
						this.setState({
							value: this.state.value + 1
						});
				}
				static getDerivedStateFromProps(props, state) {
					// These object references might be updated later so copy
					// object so we can assert their values at this snapshot in time
					propsArg = { ...props };
					stateArg = { ...state };

					// NOTE: Don't do this in real production code!
					// https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html
					return {
						value: state.value + 1
					};
				}
				render() {
					return <div>{this.state.value}</div>;
				}
			}

			// Initial render
			// state.value: initialized to 0 in constructor, 0 -> 1 in gDSFP
			render(<Foo foo="foo" />, scratch);

			let element = scratch.firstChild;
			expect(element.textContent).to.be.equal('1');
			expect(propsArg).to.deep.equal({
				foo: 'foo'
			});
			expect(stateArg).to.deep.equal({
				value: 0
			});

			// New Props
			// state.value: 1 -> 2 in gDSFP
			render(<Foo foo="bar" />, scratch);
			expect(element.textContent).to.be.equal('2');
			expect(propsArg).to.deep.equal({
				foo: 'bar'
			});
			expect(stateArg).to.deep.equal({
				value: 1
			});

			// New state
			// state.value: 2 -> 3 in updateState, 3 -> 4 in gDSFP
			updateState();
			rerender();
			expect(element.textContent).to.be.equal('4');
			expect(propsArg).to.deep.equal({
				foo: 'bar'
			});
			expect(stateArg).to.deep.equal({
				value: 3
			});

			// New Props (see #1446)
			// 4 -> 5 in gDSFP
			render(<Foo foo="baz" />, scratch);
			expect(element.textContent).to.be.equal('5');
			expect(stateArg).to.deep.equal({
				value: 4
			});

			// New Props (see #1446)
			// 5 -> 6 in gDSFP
			render(<Foo foo="qux" />, scratch);
			expect(element.textContent).to.be.equal('6');
			expect(stateArg).to.deep.equal({
				value: 5
			});
		});

		// From preactjs/preact#1170
		it('should NOT mutate state on mount, only create new versions', () => {
			const stateConstant = {};
			let componentState;

			class Stateful extends Component {
				static getDerivedStateFromProps() {
					return { key: 'value' };
				}

				constructor() {
					super(...arguments);
					this.state = stateConstant;
				}

				componentDidMount() {
					componentState = this.state;
				}

				render() {
					return <div />;
				}
			}

			render(<Stateful />, scratch);

			// Verify captured object references didn't get mutated
			expect(componentState).to.deep.equal({ key: 'value' });
			expect(stateConstant).to.deep.equal({});
		});

		it('should NOT mutate state on update, only create new versions', () => {
			const initialState = {};
			const capturedStates = [];

			let setState;

			class Stateful extends Component {
				static getDerivedStateFromProps(props, state) {
					return { value: (state.value || 0) + 1 };
				}

				constructor() {
					super(...arguments);
					this.state = initialState;
					capturedStates.push(this.state);

					setState = this.setState.bind(this);
				}

				componentDidMount() {
					capturedStates.push(this.state);
				}

				componentDidUpdate() {
					capturedStates.push(this.state);
				}

				render() {
					return <div />;
				}
			}

			render(<Stateful />, scratch);

			setState({ value: 10 });
			rerender();

			// Verify captured object references didn't get mutated
			expect(capturedStates).to.deep.equal([{}, { value: 1 }, { value: 11 }]);
		});
	});
});