/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @emails react-core
 */

'use strict';

let React;
let ReactDOM;

let TestComponent;

describe('ReactCompositeComponent-state', () => {
  beforeEach(() => {
    React = require('react');
    ReactDOM = require('react-dom');

    TestComponent = class extends React.Component {
      constructor(props) {
        super(props);
        this.peekAtState('getInitialState', undefined, props);
        this.state = {color: 'red'};
      }

      peekAtState = (from, state = this.state, props = this.props) => {
        props.stateListener(from, state && state.color);
      };

      peekAtCallback = from => {
        return () => this.peekAtState(from);
      };

      setFavoriteColor(nextColor) {
        this.setState(
          {color: nextColor},
          this.peekAtCallback('setFavoriteColor'),
        );
      }

      render() {
        this.peekAtState('render');
        return <div>{this.state.color}</div>;
      }

      UNSAFE_componentWillMount() {
        this.peekAtState('componentWillMount-start');
        this.setState(function (state) {
          this.peekAtState('before-setState-sunrise', state);
        });
        this.setState(
          {color: 'sunrise'},
          this.peekAtCallback('setState-sunrise'),
        );
        this.setState(function (state) {
          this.peekAtState('after-setState-sunrise', state);
        });
        this.peekAtState('componentWillMount-after-sunrise');
        this.setState(
          {color: 'orange'},
          this.peekAtCallback('setState-orange'),
        );
        this.setState(function (state) {
          this.peekAtState('after-setState-orange', state);
        });
        this.peekAtState('componentWillMount-end');
      }

      componentDidMount() {
        this.peekAtState('componentDidMount-start');
        this.setState(
          {color: 'yellow'},
          this.peekAtCallback('setState-yellow'),
        );
        this.peekAtState('componentDidMount-end');
      }

      UNSAFE_componentWillReceiveProps(newProps) {
        this.peekAtState('componentWillReceiveProps-start');
        if (newProps.nextColor) {
          this.setState(function (state) {
            this.peekAtState('before-setState-receiveProps', state);
            return {color: newProps.nextColor};
          });
          // No longer a public API, but we can test that it works internally by
          // reaching into the updater.
          this.updater.enqueueReplaceState(this, {color: undefined});
          this.setState(function (state) {
            this.peekAtState('before-setState-again-receiveProps', state);
            return {color: newProps.nextColor};
          }, this.peekAtCallback('setState-receiveProps'));
          this.setState(function (state) {
            this.peekAtState('after-setState-receiveProps', state);
          });
        }
        this.peekAtState('componentWillReceiveProps-end');
      }

      shouldComponentUpdate(nextProps, nextState) {
        this.peekAtState('shouldComponentUpdate-currentState');
        this.peekAtState('shouldComponentUpdate-nextState', nextState);
        return true;
      }

      UNSAFE_componentWillUpdate(nextProps, nextState) {
        this.peekAtState('componentWillUpdate-currentState');
        this.peekAtState('componentWillUpdate-nextState', nextState);
      }

      componentDidUpdate(prevProps, prevState) {
        this.peekAtState('componentDidUpdate-currentState');
        this.peekAtState('componentDidUpdate-prevState', prevState);
      }

      componentWillUnmount() {
        this.peekAtState('componentWillUnmount');
      }
    };
  });

  it('should support setting state', () => {
    const container = document.createElement('div');
    document.body.appendChild(container);

    const stateListener = jest.fn();
    const instance = ReactDOM.render(
      <TestComponent stateListener={stateListener} />,
      container,
      function peekAtInitialCallback() {
        this.peekAtState('initial-callback');
      },
    );
    ReactDOM.render(
      <TestComponent stateListener={stateListener} nextColor="green" />,
      container,
      instance.peekAtCallback('setProps'),
    );
    instance.setFavoriteColor('blue');
    instance.forceUpdate(instance.peekAtCallback('forceUpdate'));

    ReactDOM.unmountComponentAtNode(container);

    const expected = [
      // there is no state when getInitialState() is called
      ['getInitialState', null],
      ['componentWillMount-start', 'red'],
      // setState()'s only enqueue pending states.
      ['componentWillMount-after-sunrise', 'red'],
      ['componentWillMount-end', 'red'],
      // pending state queue is processed
      ['before-setState-sunrise', 'red'],
      ['after-setState-sunrise', 'sunrise'],
      ['after-setState-orange', 'orange'],
      // pending state has been applied
      ['render', 'orange'],
      ['componentDidMount-start', 'orange'],
      // setState-sunrise and setState-orange should be called here,
      // after the bug in #1740
      // componentDidMount() called setState({color:'yellow'}), which is async.
      // The update doesn't happen until the next flush.
      ['componentDidMount-end', 'orange'],
      ['setState-sunrise', 'orange'],
      ['setState-orange', 'orange'],
      ['initial-callback', 'orange'],
      ['shouldComponentUpdate-currentState', 'orange'],
      ['shouldComponentUpdate-nextState', 'yellow'],
      ['componentWillUpdate-currentState', 'orange'],
      ['componentWillUpdate-nextState', 'yellow'],
      ['render', 'yellow'],
      ['componentDidUpdate-currentState', 'yellow'],
      ['componentDidUpdate-prevState', 'orange'],
      ['setState-yellow', 'yellow'],
      ['componentWillReceiveProps-start', 'yellow'],
      // setState({color:'green'}) only enqueues a pending state.
      ['componentWillReceiveProps-end', 'yellow'],
      // pending state queue is processed
      // We keep updates in the queue to support
      // replaceState(prevState => newState).
      ['before-setState-receiveProps', 'yellow'],
      ['before-setState-again-receiveProps', undefined],
      ['after-setState-receiveProps', 'green'],
      ['shouldComponentUpdate-currentState', 'yellow'],
      ['shouldComponentUpdate-nextState', 'green'],
      ['componentWillUpdate-currentState', 'yellow'],
      ['componentWillUpdate-nextState', 'green'],
      ['render', 'green'],
      ['componentDidUpdate-currentState', 'green'],
      ['componentDidUpdate-prevState', 'yellow'],
      ['setState-receiveProps', 'green'],
      ['setProps', 'green'],
      // setFavoriteColor('blue')
      ['shouldComponentUpdate-currentState', 'green'],
      ['shouldComponentUpdate-nextState', 'blue'],
      ['componentWillUpdate-currentState', 'green'],
      ['componentWillUpdate-nextState', 'blue'],
      ['render', 'blue'],
      ['componentDidUpdate-currentState', 'blue'],
      ['componentDidUpdate-prevState', 'green'],
      ['setFavoriteColor', 'blue'],
      // forceUpdate()
      ['componentWillUpdate-currentState', 'blue'],
      ['componentWillUpdate-nextState', 'blue'],
      ['render', 'blue'],
      ['componentDidUpdate-currentState', 'blue'],
      ['componentDidUpdate-prevState', 'blue'],
      ['forceUpdate', 'blue'],
      // unmountComponent()
      // state is available within `componentWillUnmount()`
      ['componentWillUnmount', 'blue'],
    ];

    expect(stateListener.mock.calls.join('\n')).toEqual(expected.join('\n'));
  });

  it('should call componentDidUpdate of children first', () => {
    const container = document.createElement('div');

    let ops = [];

    let child = null;
    let parent = null;

    class Child extends React.Component {
      state = {bar: false};
      componentDidMount() {
        child = this;
      }
      componentDidUpdate() {
        ops.push('child did update');
      }
      render() {
        return <div />;
      }
    }

    let shouldUpdate = true;

    class Intermediate extends React.Component {
      shouldComponentUpdate() {
        return shouldUpdate;
      }
      render() {
        return <Child />;
      }
    }

    class Parent extends React.Component {
      state = {foo: false};
      componentDidMount() {
        parent = this;
      }
      componentDidUpdate() {
        ops.push('parent did update');
      }
      render() {
        return <Intermediate />;
      }
    }

    ReactDOM.render(<Parent />, container);

    ReactDOM.unstable_batchedUpdates(() => {
      parent.setState({foo: true});
      child.setState({bar: true});
    });
    // When we render changes top-down in a batch, children's componentDidUpdate
    // happens before the parent.
    expect(ops).toEqual(['child did update', 'parent did update']);

    shouldUpdate = false;

    ops = [];

    ReactDOM.unstable_batchedUpdates(() => {
      parent.setState({foo: false});
      child.setState({bar: false});
    });
    // We expect the same thing to happen if we bail out in the middle.
    expect(ops).toEqual(['child did update', 'parent did update']);
  });

  it('should batch unmounts', () => {
    class Inner extends React.Component {
      render() {
        return <div />;
      }

      componentWillUnmount() {
        // This should get silently ignored (maybe with a warning), but it
        // shouldn't break React.
        outer.setState({showInner: false});
      }
    }

    class Outer extends React.Component {
      state = {showInner: true};

      render() {
        return <div>{this.state.showInner && <Inner />}</div>;
      }
    }

    const container = document.createElement('div');
    const outer = ReactDOM.render(<Outer />, container);
    expect(() => {
      ReactDOM.unmountComponentAtNode(container);
    }).not.toThrow();
  });

  it('should update state when called from child cWRP', function () {
    const log = [];
    class Parent extends React.Component {
      state = {value: 'one'};
      render() {
        log.push('parent render ' + this.state.value);
        return <Child parent={this} value={this.state.value} />;
      }
    }
    let updated = false;
    class Child extends React.Component {
      UNSAFE_componentWillReceiveProps() {
        if (updated) {
          return;
        }
        log.push('child componentWillReceiveProps ' + this.props.value);
        this.props.parent.setState({value: 'two'});
        log.push('child componentWillReceiveProps done ' + this.props.value);
        updated = true;
      }
      render() {
        log.push('child render ' + this.props.value);
        return <div>{this.props.value}</div>;
      }
    }
    const container = document.createElement('div');
    ReactDOM.render(<Parent />, container);
    ReactDOM.render(<Parent />, container);
    expect(log).toEqual([
      'parent render one',
      'child render one',
      'parent render one',
      'child componentWillReceiveProps one',
      'child componentWillReceiveProps done one',
      'child render one',
      'parent render two',
      'child render two',
    ]);
  });

  it('should merge state when sCU returns false', function () {
    const log = [];
    class Test extends React.Component {
      state = {a: 0};
      render() {
        return null;
      }
      shouldComponentUpdate(nextProps, nextState) {
        log.push(
          'scu from ' +
            Object.keys(this.state) +
            ' to ' +
            Object.keys(nextState),
        );
        return false;
      }
    }

    const container = document.createElement('div');
    const test = ReactDOM.render(<Test />, container);
    test.setState({b: 0});
    expect(log.length).toBe(1);
    test.setState({c: 0});
    expect(log.length).toBe(2);
    expect(log).toEqual(['scu from a to a,b', 'scu from a,b to a,b,c']);
  });

  it('should treat assigning to this.state inside cWRP as a replaceState, with a warning', () => {
    const ops = [];
    class Test extends React.Component {
      state = {step: 1, extra: true};
      UNSAFE_componentWillReceiveProps() {
        this.setState({step: 2}, () => {
          // Tests that earlier setState callbacks are not dropped
          ops.push(
            `callback -- step: ${this.state.step}, extra: ${!!this.state
              .extra}`,
          );
        });
        // Treat like replaceState
        this.state = {step: 3};
      }
      render() {
        ops.push(
          `render -- step: ${this.state.step}, extra: ${!!this.state.extra}`,
        );
        return null;
      }
    }

    // Mount
    const container = document.createElement('div');
    ReactDOM.render(<Test />, container);
    // Update
    expect(() => ReactDOM.render(<Test />, container)).toErrorDev(
      'Warning: Test.componentWillReceiveProps(): Assigning directly to ' +
        "this.state is deprecated (except inside a component's constructor). " +
        'Use setState instead.',
    );

    expect(ops).toEqual([
      'render -- step: 1, extra: true',
      'render -- step: 3, extra: false',
      'callback -- step: 3, extra: false',
    ]);

    // Check deduplication; (no additional warnings are expected)
    ReactDOM.render(<Test />, container);
  });

  it('should treat assigning to this.state inside cWM as a replaceState, with a warning', () => {
    const ops = [];
    class Test extends React.Component {
      state = {step: 1, extra: true};
      UNSAFE_componentWillMount() {
        this.setState({step: 2}, () => {
          // Tests that earlier setState callbacks are not dropped
          ops.push(
            `callback -- step: ${this.state.step}, extra: ${!!this.state
              .extra}`,
          );
        });
        // Treat like replaceState
        this.state = {step: 3};
      }
      render() {
        ops.push(
          `render -- step: ${this.state.step}, extra: ${!!this.state.extra}`,
        );
        return null;
      }
    }

    // Mount
    const container = document.createElement('div');
    expect(() => ReactDOM.render(<Test />, container)).toErrorDev(
      'Warning: Test.componentWillMount(): Assigning directly to ' +
        "this.state is deprecated (except inside a component's constructor). " +
        'Use setState instead.',
    );

    expect(ops).toEqual([
      'render -- step: 3, extra: false',
      'callback -- step: 3, extra: false',
    ]);
  });

  if (!require('shared/ReactFeatureFlags').disableModulePatternComponents) {
    it('should support stateful module pattern components', () => {
      function Child() {
        return {
          state: {
            count: 123,
          },
          render() {
            return <div>{`count:${this.state.count}`}</div>;
          },
        };
      }

      const el = document.createElement('div');
      expect(() => ReactDOM.render(<Child />, el)).toErrorDev(
        'Warning: The <Child /> component appears to be a function component that returns a class instance. ' +
          'Change Child to a class that extends React.Component instead. ' +
          "If you can't use a class try assigning the prototype on the function as a workaround. " +
          '`Child.prototype = React.Component.prototype`. ' +
          "Don't use an arrow function since it cannot be called with `new` by React.",
      );

      expect(el.textContent).toBe('count:123');
    });

    it('should support getDerivedStateFromProps for module pattern components', () => {
      function Child() {
        return {
          state: {
            count: 1,
          },
          render() {
            return <div>{`count:${this.state.count}`}</div>;
          },
        };
      }
      Child.getDerivedStateFromProps = (props, prevState) => {
        return {
          count: prevState.count + props.incrementBy,
        };
      };

      const el = document.createElement('div');
      ReactDOM.render(<Child incrementBy={0} />, el);
      expect(el.textContent).toBe('count:1');

      ReactDOM.render(<Child incrementBy={2} />, el);
      expect(el.textContent).toBe('count:3');

      ReactDOM.render(<Child incrementBy={1} />, el);
      expect(el.textContent).toBe('count:4');
    });
  }

  it('should support setState in componentWillUnmount', () => {
    let subscription;
    class A extends React.Component {
      componentWillUnmount() {
        subscription();
      }
      render() {
        return 'A';
      }
    }

    class B extends React.Component {
      state = {siblingUnmounted: false};
      UNSAFE_componentWillMount() {
        subscription = () => this.setState({siblingUnmounted: true});
      }
      render() {
        return 'B' + (this.state.siblingUnmounted ? ' No Sibling' : '');
      }
    }

    const el = document.createElement('div');
    ReactDOM.render(<A />, el);
    expect(el.textContent).toBe('A');

    ReactDOM.render(<B />, el);
    expect(el.textContent).toBe('B No Sibling');
  });
});