diff options
Diffstat (limited to 'preact/test/browser/lifecycles/lifecycle.test.js')
-rw-r--r-- | preact/test/browser/lifecycles/lifecycle.test.js | 672 |
1 files changed, 672 insertions, 0 deletions
diff --git a/preact/test/browser/lifecycles/lifecycle.test.js b/preact/test/browser/lifecycles/lifecycle.test.js new file mode 100644 index 0000000..56cced6 --- /dev/null +++ b/preact/test/browser/lifecycles/lifecycle.test.js @@ -0,0 +1,672 @@ +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); + }); + + it('should call nested new lifecycle methods in the right order', () => { + let updateOuterState; + let updateInnerState; + let forceUpdateOuter; + let forceUpdateInner; + + let log; + function logger(msg) { + return function() { + // return true for shouldComponentUpdate + log.push(msg); + return true; + }; + } + + class Outer extends Component { + static getDerivedStateFromProps() { + log.push('outer getDerivedStateFromProps'); + return null; + } + constructor() { + super(); + log.push('outer constructor'); + + this.state = { value: 0 }; + forceUpdateOuter = () => + this.forceUpdate(() => log.push('outer forceUpdate callback')); + updateOuterState = () => + this.setState( + prevState => ({ value: prevState.value % 2 }), + () => log.push('outer setState callback') + ); + } + render() { + log.push('outer render'); + return ( + <div> + <Inner x={this.props.x} outerValue={this.state.value} /> + </div> + ); + } + } + Object.assign(Outer.prototype, { + componentDidMount: logger('outer componentDidMount'), + shouldComponentUpdate: logger('outer shouldComponentUpdate'), + getSnapshotBeforeUpdate: logger('outer getSnapshotBeforeUpdate'), + componentDidUpdate: logger('outer componentDidUpdate'), + componentWillUnmount: logger('outer componentWillUnmount') + }); + + class Inner extends Component { + static getDerivedStateFromProps() { + log.push('inner getDerivedStateFromProps'); + return null; + } + constructor() { + super(); + log.push('inner constructor'); + + this.state = { value: 0 }; + forceUpdateInner = () => + this.forceUpdate(() => log.push('inner forceUpdate callback')); + updateInnerState = () => + this.setState( + prevState => ({ value: prevState.value % 2 }), + () => log.push('inner setState callback') + ); + } + render() { + log.push('inner render'); + return ( + <span> + {this.props.x} {this.props.outerValue} {this.state.value} + </span> + ); + } + } + Object.assign(Inner.prototype, { + componentDidMount: logger('inner componentDidMount'), + shouldComponentUpdate: logger('inner shouldComponentUpdate'), + getSnapshotBeforeUpdate: logger('inner getSnapshotBeforeUpdate'), + componentDidUpdate: logger('inner componentDidUpdate'), + componentWillUnmount: logger('inner componentWillUnmount') + }); + + // Constructor & mounting + log = []; + render(<Outer x={1} />, scratch); + expect(log).to.deep.equal([ + 'outer constructor', + 'outer getDerivedStateFromProps', + 'outer render', + 'inner constructor', + 'inner getDerivedStateFromProps', + 'inner render', + 'inner componentDidMount', + 'outer componentDidMount' + ]); + + // Outer & Inner props update + log = []; + render(<Outer x={2} />, scratch); + // Note: we differ from react here in that we apply changes to the dom + // as we find them while diffing. React on the other hand separates this + // into specific phases, meaning changes to the dom are only flushed + // once the whole diff-phase is complete. This is why + // "outer getSnapshotBeforeUpdate" is called just before the "inner" hooks. + // For react this call would be right before "outer componentDidUpdate" + expect(log).to.deep.equal([ + 'outer getDerivedStateFromProps', + 'outer shouldComponentUpdate', + 'outer render', + 'outer getSnapshotBeforeUpdate', + 'inner getDerivedStateFromProps', + 'inner shouldComponentUpdate', + 'inner render', + 'inner getSnapshotBeforeUpdate', + 'inner componentDidUpdate', + 'outer componentDidUpdate' + ]); + + // Outer state update & Inner props update + log = []; + updateOuterState(); + rerender(); + expect(log).to.deep.equal([ + 'outer getDerivedStateFromProps', + 'outer shouldComponentUpdate', + 'outer render', + 'outer getSnapshotBeforeUpdate', + 'inner getDerivedStateFromProps', + 'inner shouldComponentUpdate', + 'inner render', + 'inner getSnapshotBeforeUpdate', + 'inner componentDidUpdate', + 'outer setState callback', + 'outer componentDidUpdate' + ]); + + // Inner state update + log = []; + updateInnerState(); + rerender(); + expect(log).to.deep.equal([ + 'inner getDerivedStateFromProps', + 'inner shouldComponentUpdate', + 'inner render', + 'inner getSnapshotBeforeUpdate', + 'inner setState callback', + 'inner componentDidUpdate' + ]); + + // Force update Outer + log = []; + forceUpdateOuter(); + rerender(); + expect(log).to.deep.equal([ + 'outer getDerivedStateFromProps', + 'outer render', + 'outer getSnapshotBeforeUpdate', + 'inner getDerivedStateFromProps', + 'inner shouldComponentUpdate', + 'inner render', + 'inner getSnapshotBeforeUpdate', + 'inner componentDidUpdate', + 'outer forceUpdate callback', + 'outer componentDidUpdate' + ]); + + // Force update Inner + log = []; + forceUpdateInner(); + rerender(); + expect(log).to.deep.equal([ + 'inner getDerivedStateFromProps', + 'inner render', + 'inner getSnapshotBeforeUpdate', + 'inner forceUpdate callback', + 'inner componentDidUpdate' + ]); + + // Unmounting Outer & Inner + log = []; + render(<table />, scratch); + expect(log).to.deep.equal([ + 'outer componentWillUnmount', + 'inner componentWillUnmount' + ]); + }); + + describe('#constructor and component(Did|Will)(Mount|Unmount)', () => { + let setState; + class Outer extends Component { + constructor(p, c) { + super(p, c); + this.state = { show: true }; + setState = s => this.setState(s); + } + render(props, { show }) { + return <div>{show && <Inner {...props} />}</div>; + } + } + + class LifecycleTestComponent extends Component { + componentWillMount() {} + componentDidMount() {} + componentWillUnmount() {} + render() { + return <div />; + } + } + + class Inner extends LifecycleTestComponent { + render() { + return ( + <div> + <InnerMost /> + </div> + ); + } + } + + class InnerMost extends LifecycleTestComponent { + render() { + return <div />; + } + } + + let spies = [ + 'componentWillMount', + 'componentDidMount', + 'componentWillUnmount' + ]; + + let verifyLifecycleMethods = TestComponent => { + let proto = TestComponent.prototype; + spies.forEach(s => sinon.spy(proto, s)); + let reset = () => spies.forEach(s => proto[s].resetHistory()); + + it('should be invoked for components on initial render', () => { + reset(); + render(<Outer />, scratch); + expect(proto.componentDidMount).to.have.been.called; + expect(proto.componentWillMount).to.have.been.calledBefore( + proto.componentDidMount + ); + expect(proto.componentDidMount).to.have.been.called; + }); + + it('should be invoked for components on unmount', () => { + reset(); + setState({ show: false }); + rerender(); + + expect(proto.componentWillUnmount).to.have.been.called; + }); + + it('should be invoked for components on re-render', () => { + reset(); + setState({ show: true }); + rerender(); + + expect(proto.componentDidMount).to.have.been.called; + expect(proto.componentWillMount).to.have.been.calledBefore( + proto.componentDidMount + ); + expect(proto.componentDidMount).to.have.been.called; + }); + }; + + describe('inner components', () => { + verifyLifecycleMethods(Inner); + }); + + describe('innermost components', () => { + verifyLifecycleMethods(InnerMost); + }); + + describe('when shouldComponentUpdate() returns false', () => { + let setState; + + class Outer extends Component { + constructor() { + super(); + this.state = { show: true }; + setState = s => this.setState(s); + } + render(props, { show }) { + return ( + <div> + {show && ( + <div> + <Inner {...props} /> + </div> + )} + </div> + ); + } + } + + class Inner extends Component { + shouldComponentUpdate() { + return false; + } + componentWillMount() {} + componentDidMount() {} + componentWillUnmount() {} + render() { + return <div />; + } + } + + let proto = Inner.prototype; + let spies = [ + 'componentWillMount', + 'componentDidMount', + 'componentWillUnmount' + ]; + spies.forEach(s => sinon.spy(proto, s)); + + let reset = () => spies.forEach(s => proto[s].resetHistory()); + + beforeEach(() => reset()); + + it('should be invoke normally on initial mount', () => { + render(<Outer />, scratch); + expect(proto.componentWillMount).to.have.been.called; + expect(proto.componentWillMount).to.have.been.calledBefore( + proto.componentDidMount + ); + expect(proto.componentDidMount).to.have.been.called; + }); + + it('should be invoked normally on unmount', () => { + setState({ show: false }); + rerender(); + + expect(proto.componentWillUnmount).to.have.been.called; + }); + + it('should still invoke mount for shouldComponentUpdate():false', () => { + setState({ show: true }); + rerender(); + + expect(proto.componentWillMount).to.have.been.called; + expect(proto.componentWillMount).to.have.been.calledBefore( + proto.componentDidMount + ); + expect(proto.componentDidMount).to.have.been.called; + }); + + it('should still invoke unmount for shouldComponentUpdate():false', () => { + setState({ show: false }); + rerender(); + + expect(proto.componentWillUnmount).to.have.been.called; + }); + }); + }); + + describe('#setState', () => { + // From preactjs/preact#1170 + it('should NOT mutate state, only create new versions', () => { + const stateConstant = {}; + let didMount = false; + let componentState; + + class Stateful extends Component { + constructor() { + super(...arguments); + this.state = stateConstant; + } + + componentDidMount() { + didMount = true; + + // eslint-disable-next-line react/no-did-mount-set-state + this.setState({ key: 'value' }, () => { + componentState = this.state; + }); + } + + render() { + return <div />; + } + } + + render(<Stateful />, scratch); + rerender(); + + expect(didMount).to.equal(true); + expect(componentState).to.deep.equal({ key: 'value' }); + expect(stateConstant).to.deep.equal({}); + }); + + // This feature is not mentioned in the docs, but is part of the release + // notes for react v16.0.0: https://reactjs.org/blog/2017/09/26/react-v16.0.html#breaking-changes + it('should abort if updater function returns null', () => { + let updateState; + class Foo extends Component { + constructor() { + super(); + this.state = { value: 0 }; + updateState = () => + this.setState(prev => { + prev.value++; + return null; + }); + } + + render() { + return 'value: ' + this.state.value; + } + } + + let renderSpy = sinon.spy(Foo.prototype, 'render'); + render(<Foo />, scratch); + renderSpy.resetHistory(); + + updateState(); + rerender(); + expect(renderSpy).to.not.be.called; + }); + + it('should call callback with correct this binding', () => { + let inst; + let updateState; + class Foo extends Component { + constructor() { + super(); + updateState = () => this.setState({}, this.onUpdate); + } + + onUpdate() { + inst = this; + } + } + + render(<Foo />, scratch); + updateState(); + rerender(); + + expect(inst).to.be.instanceOf(Foo); + }); + }); + + describe('Lifecycle DOM Timing', () => { + it('should be invoked when dom does (DidMount, WillUnmount) or does not (WillMount, DidUnmount) exist', () => { + let setState; + class Outer extends Component { + constructor() { + super(); + this.state = { show: true }; + setState = s => { + this.setState(s); + this.forceUpdate(); + }; + } + componentWillMount() { + expect( + document.getElementById('OuterDiv'), + 'Outer componentWillMount' + ).to.not.exist; + } + componentDidMount() { + expect(document.getElementById('OuterDiv'), 'Outer componentDidMount') + .to.exist; + } + componentWillUnmount() { + expect( + document.getElementById('OuterDiv'), + 'Outer componentWillUnmount' + ).to.exist; + setTimeout(() => { + expect( + document.getElementById('OuterDiv'), + 'Outer after componentWillUnmount' + ).to.not.exist; + }, 0); + } + render(props, { show }) { + return ( + <div id="OuterDiv"> + {show && ( + <div> + <Inner {...props} /> + </div> + )} + </div> + ); + } + } + + class Inner extends Component { + componentWillMount() { + expect( + document.getElementById('InnerDiv'), + 'Inner componentWillMount' + ).to.not.exist; + } + componentDidMount() { + expect(document.getElementById('InnerDiv'), 'Inner componentDidMount') + .to.exist; + } + componentWillUnmount() { + // @TODO Component mounted into elements (non-components) + // are currently unmounted after those elements, so their + // DOM is unmounted prior to the method being called. + //expect(document.getElementById('InnerDiv'), 'Inner componentWillUnmount').to.exist; + setTimeout(() => { + expect( + document.getElementById('InnerDiv'), + 'Inner after componentWillUnmount' + ).to.not.exist; + }, 0); + } + + render() { + return <div id="InnerDiv" />; + } + } + + let proto = Inner.prototype; + let spies = [ + 'componentWillMount', + 'componentDidMount', + 'componentWillUnmount' + ]; + spies.forEach(s => sinon.spy(proto, s)); + + let reset = () => spies.forEach(s => proto[s].resetHistory()); + + render(<Outer />, scratch); + expect(proto.componentWillMount).to.have.been.called; + expect(proto.componentWillMount).to.have.been.calledBefore( + proto.componentDidMount + ); + expect(proto.componentDidMount).to.have.been.called; + + reset(); + setState({ show: false }); + rerender(); + + expect(proto.componentWillUnmount).to.have.been.called; + + reset(); + setState({ show: true }); + rerender(); + + expect(proto.componentWillMount).to.have.been.called; + expect(proto.componentWillMount).to.have.been.calledBefore( + proto.componentDidMount + ); + expect(proto.componentDidMount).to.have.been.called; + }); + + it('should be able to use getDerivedStateFromError and componentDidCatch together', () => { + let didCatch = sinon.spy(), + getDerived = sinon.spy(); + const error = new Error('hi'); + + class Boundary extends Component { + static getDerivedStateFromError(err) { + getDerived(err); + return { err }; + } + + componentDidCatch(err) { + didCatch(err); + } + + render() { + return this.state.err ? <div /> : this.props.children; + } + } + + const ThrowErr = () => { + throw error; + }; + + render( + <Boundary> + <ThrowErr /> + </Boundary>, + scratch + ); + rerender(); + + expect(didCatch).to.have.been.calledWith(error); + + expect(getDerived).to.have.been.calledWith(error); + }); + + it('should remove this.base for HOC', () => { + let createComponent = (name, fn) => { + class C extends Component { + componentWillUnmount() { + expect(this.base, `${name}.componentWillUnmount`).to.exist; + setTimeout(() => { + expect(this.base, `after ${name}.componentWillUnmount`).not.to + .exist; + }, 0); + } + render(props) { + return fn(props); + } + } + sinon.spy(C.prototype, 'componentWillUnmount'); + sinon.spy(C.prototype, 'render'); + return C; + }; + + class Wrapper extends Component { + render({ children }) { + return <div class="wrapper">{children}</div>; + } + } + + let One = createComponent('One', () => <Wrapper>one</Wrapper>); + let Two = createComponent('Two', () => <Wrapper>two</Wrapper>); + let Three = createComponent('Three', () => <Wrapper>three</Wrapper>); + + let components = [One, Two, Three]; + + let Selector = createComponent('Selector', ({ page }) => { + let Child = components[page]; + return Child && <Child />; + }); + + let app; + class App extends Component { + constructor() { + super(); + app = this; + } + + render(_, { page }) { + return <Selector page={page} />; + } + } + + render(<App />, scratch); + + for (let i = 0; i < 20; i++) { + app.setState({ page: i % components.length }); + app.forceUpdate(); + } + }); + }); +}); |