/**
 * Copyright (c) Facebook, Inc. and its 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
 * @jest-environment node
 */

'use strict';

let React;
let ReactNoop;
let Scheduler;
let ImmediatePriority;
let UserBlockingPriority;
let NormalPriority;
let LowPriority;
let IdlePriority;
let runWithPriority;

describe('ReactSchedulerIntegration', () => {
  beforeEach(() => {
    jest.resetModules();

    React = require('react');
    ReactNoop = require('react-noop-renderer');
    Scheduler = require('scheduler');
    ImmediatePriority = Scheduler.unstable_ImmediatePriority;
    UserBlockingPriority = Scheduler.unstable_UserBlockingPriority;
    NormalPriority = Scheduler.unstable_NormalPriority;
    LowPriority = Scheduler.unstable_LowPriority;
    IdlePriority = Scheduler.unstable_IdlePriority;
    runWithPriority = Scheduler.unstable_runWithPriority;
  });

  function getCurrentPriorityAsString() {
    const priorityLevel = Scheduler.unstable_getCurrentPriorityLevel();
    switch (priorityLevel) {
      case ImmediatePriority:
        return 'Immediate';
      case UserBlockingPriority:
        return 'UserBlocking';
      case NormalPriority:
        return 'Normal';
      case LowPriority:
        return 'Low';
      case IdlePriority:
        return 'Idle';
      default:
        throw Error('Unknown priority level: ' + priorityLevel);
    }
  }

  // Note: This is based on a similar component we use in www. We can delete
  // once the extra div wrapper is no longer necessary.
  function LegacyHiddenDiv({children, mode}) {
    return (
      <div hidden={mode === 'hidden'}>
        <React.unstable_LegacyHidden
          mode={mode === 'hidden' ? 'unstable-defer-without-hiding' : mode}>
          {children}
        </React.unstable_LegacyHidden>
      </div>
    );
  }

  it('flush sync has correct priority', () => {
    function ReadPriority() {
      Scheduler.unstable_yieldValue(
        'Priority: ' + getCurrentPriorityAsString(),
      );
      return null;
    }
    ReactNoop.flushSync(() => ReactNoop.render(<ReadPriority />));
    expect(Scheduler).toHaveYielded(['Priority: Immediate']);
  });

  it('has correct priority during rendering', () => {
    function ReadPriority() {
      Scheduler.unstable_yieldValue(
        'Priority: ' + getCurrentPriorityAsString(),
      );
      return null;
    }
    ReactNoop.render(<ReadPriority />);
    expect(Scheduler).toFlushAndYield(['Priority: Normal']);

    runWithPriority(UserBlockingPriority, () => {
      ReactNoop.render(<ReadPriority />);
    });
    expect(Scheduler).toFlushAndYield(['Priority: UserBlocking']);

    runWithPriority(IdlePriority, () => {
      ReactNoop.render(<ReadPriority />);
    });
    expect(Scheduler).toFlushAndYield(['Priority: Idle']);
  });

  it('has correct priority when continuing a render after yielding', () => {
    function ReadPriority() {
      Scheduler.unstable_yieldValue(
        'Priority: ' + getCurrentPriorityAsString(),
      );
      return null;
    }

    runWithPriority(UserBlockingPriority, () => {
      ReactNoop.render(
        <>
          <ReadPriority />
          <ReadPriority />
          <ReadPriority />
        </>,
      );
    });

    // Render part of the tree
    expect(Scheduler).toFlushAndYieldThrough(['Priority: UserBlocking']);

    // Priority is set back to normal when yielding
    expect(getCurrentPriorityAsString()).toEqual('Normal');

    // Priority is restored to user-blocking when continuing
    expect(Scheduler).toFlushAndYield([
      'Priority: UserBlocking',
      'Priority: UserBlocking',
    ]);
  });

  it('layout effects have immediate priority', () => {
    const {useLayoutEffect} = React;
    function ReadPriority() {
      Scheduler.unstable_yieldValue(
        'Render priority: ' + getCurrentPriorityAsString(),
      );
      useLayoutEffect(() => {
        Scheduler.unstable_yieldValue(
          'Layout priority: ' + getCurrentPriorityAsString(),
        );
      });
      return null;
    }

    ReactNoop.render(<ReadPriority />);
    expect(Scheduler).toFlushAndYield([
      'Render priority: Normal',
      'Layout priority: Immediate',
    ]);
  });

  it('passive effects never have higher than normal priority', async () => {
    const {useEffect} = React;
    function ReadPriority({step}) {
      Scheduler.unstable_yieldValue(
        `Render priority: ${getCurrentPriorityAsString()}`,
      );
      useEffect(() => {
        Scheduler.unstable_yieldValue(
          `Effect priority: ${getCurrentPriorityAsString()}`,
        );
        return () => {
          Scheduler.unstable_yieldValue(
            `Effect clean-up priority: ${getCurrentPriorityAsString()}`,
          );
        };
      });
      return null;
    }

    // High priority renders spawn effects at normal priority
    await ReactNoop.act(async () => {
      Scheduler.unstable_runWithPriority(ImmediatePriority, () => {
        ReactNoop.render(<ReadPriority />);
      });
    });
    expect(Scheduler).toHaveYielded([
      'Render priority: Immediate',
      'Effect priority: Normal',
    ]);
    await ReactNoop.act(async () => {
      Scheduler.unstable_runWithPriority(UserBlockingPriority, () => {
        ReactNoop.render(<ReadPriority />);
      });
    });
    expect(Scheduler).toHaveYielded([
      'Render priority: UserBlocking',
      'Effect clean-up priority: Normal',
      'Effect priority: Normal',
    ]);

    // Renders lower than normal priority spawn effects at the same priority
    await ReactNoop.act(async () => {
      Scheduler.unstable_runWithPriority(IdlePriority, () => {
        ReactNoop.render(<ReadPriority />);
      });
    });
    expect(Scheduler).toHaveYielded([
      'Render priority: Idle',
      'Effect clean-up priority: Idle',
      'Effect priority: Idle',
    ]);
  });

  it('passive effects have correct priority even if they are flushed early', async () => {
    const {useEffect} = React;
    function ReadPriority({step}) {
      Scheduler.unstable_yieldValue(
        `Render priority [step ${step}]: ${getCurrentPriorityAsString()}`,
      );
      useEffect(() => {
        Scheduler.unstable_yieldValue(
          `Effect priority [step ${step}]: ${getCurrentPriorityAsString()}`,
        );
      });
      return null;
    }
    await ReactNoop.act(async () => {
      ReactNoop.render(<ReadPriority step={1} />);
      Scheduler.unstable_flushUntilNextPaint();
      expect(Scheduler).toHaveYielded(['Render priority [step 1]: Normal']);
      Scheduler.unstable_runWithPriority(UserBlockingPriority, () => {
        ReactNoop.render(<ReadPriority step={2} />);
      });
    });
    expect(Scheduler).toHaveYielded([
      'Effect priority [step 1]: Normal',
      'Render priority [step 2]: UserBlocking',
      'Effect priority [step 2]: Normal',
    ]);
  });

  it('passive effect clean-up functions have correct priority even when component is deleted', async () => {
    const {useEffect} = React;
    function ReadPriority({step}) {
      useEffect(() => {
        return () => {
          Scheduler.unstable_yieldValue(
            `Effect clean-up priority: ${getCurrentPriorityAsString()}`,
          );
        };
      });
      return null;
    }

    await ReactNoop.act(async () => {
      ReactNoop.render(<ReadPriority />);
    });
    await ReactNoop.act(async () => {
      Scheduler.unstable_runWithPriority(ImmediatePriority, () => {
        ReactNoop.render(null);
      });
    });
    expect(Scheduler).toHaveYielded(['Effect clean-up priority: Normal']);

    await ReactNoop.act(async () => {
      ReactNoop.render(<ReadPriority />);
    });
    await ReactNoop.act(async () => {
      Scheduler.unstable_runWithPriority(UserBlockingPriority, () => {
        ReactNoop.render(null);
      });
    });
    expect(Scheduler).toHaveYielded(['Effect clean-up priority: Normal']);

    // Renders lower than normal priority spawn effects at the same priority
    await ReactNoop.act(async () => {
      ReactNoop.render(<ReadPriority />);
    });
    await ReactNoop.act(async () => {
      Scheduler.unstable_runWithPriority(IdlePriority, () => {
        ReactNoop.render(null);
      });
    });
    expect(Scheduler).toHaveYielded(['Effect clean-up priority: Idle']);
  });

  it('passive effects are called before Normal-pri scheduled in layout effects', async () => {
    const {useEffect, useLayoutEffect} = React;
    function Effects({step}) {
      useLayoutEffect(() => {
        Scheduler.unstable_yieldValue('Layout Effect');
        Scheduler.unstable_scheduleCallback(NormalPriority, () =>
          Scheduler.unstable_yieldValue(
            'Scheduled Normal Callback from Layout Effect',
          ),
        );
      });
      useEffect(() => {
        Scheduler.unstable_yieldValue('Passive Effect');
      });
      return null;
    }
    function CleanupEffect() {
      useLayoutEffect(() => () => {
        Scheduler.unstable_yieldValue('Cleanup Layout Effect');
        Scheduler.unstable_scheduleCallback(NormalPriority, () =>
          Scheduler.unstable_yieldValue(
            'Scheduled Normal Callback from Cleanup Layout Effect',
          ),
        );
      });
      return null;
    }
    await ReactNoop.act(async () => {
      ReactNoop.render(<CleanupEffect />);
    });
    expect(Scheduler).toHaveYielded([]);
    await ReactNoop.act(async () => {
      ReactNoop.render(<Effects />);
    });
    expect(Scheduler).toHaveYielded([
      'Cleanup Layout Effect',
      'Layout Effect',
      'Passive Effect',
      // These callbacks should be scheduled after the passive effects.
      'Scheduled Normal Callback from Cleanup Layout Effect',
      'Scheduled Normal Callback from Layout Effect',
    ]);
  });

  it('after completing a level of work, infers priority of the next batch based on its expiration time', () => {
    function App({label}) {
      Scheduler.unstable_yieldValue(
        `${label} [${getCurrentPriorityAsString()}]`,
      );
      return label;
    }

    // Schedule two separate updates at different priorities
    runWithPriority(UserBlockingPriority, () => {
      ReactNoop.render(<App label="A" />);
    });
    ReactNoop.render(<App label="B" />);

    // The second update should run at normal priority
    expect(Scheduler).toFlushAndYield(['A [UserBlocking]', 'B [Normal]']);
  });

  it('requests a paint after committing', () => {
    const scheduleCallback = Scheduler.unstable_scheduleCallback;

    const root = ReactNoop.createRoot();
    root.render('Initial');
    Scheduler.unstable_flushAll();

    scheduleCallback(NormalPriority, () => Scheduler.unstable_yieldValue('A'));
    scheduleCallback(NormalPriority, () => Scheduler.unstable_yieldValue('B'));
    scheduleCallback(NormalPriority, () => Scheduler.unstable_yieldValue('C'));

    // Schedule a React render. React will request a paint after committing it.
    root.render('Update');

    // Advance time just to be sure the next tasks have lower priority
    Scheduler.unstable_advanceTime(2000);

    scheduleCallback(NormalPriority, () => Scheduler.unstable_yieldValue('D'));
    scheduleCallback(NormalPriority, () => Scheduler.unstable_yieldValue('E'));

    // Flush everything up to the next paint. Should yield after the
    // React commit.
    Scheduler.unstable_flushUntilNextPaint();
    expect(Scheduler).toHaveYielded(['A', 'B', 'C']);
  });

  // @gate experimental
  it('idle updates are not blocked by offscreen work', async () => {
    function Text({text}) {
      Scheduler.unstable_yieldValue(text);
      return text;
    }

    function App({label}) {
      return (
        <>
          <Text text={`Visible: ` + label} />
          <LegacyHiddenDiv mode="hidden">
            <Text text={`Hidden: ` + label} />
          </LegacyHiddenDiv>
        </>
      );
    }

    const root = ReactNoop.createRoot();
    await ReactNoop.act(async () => {
      root.render(<App label="A" />);

      // Commit the visible content
      expect(Scheduler).toFlushUntilNextPaint(['Visible: A']);
      expect(root).toMatchRenderedOutput(
        <>
          Visible: A
          <div hidden={true} />
        </>,
      );

      // Before the hidden content has a chance to render, schedule an
      // idle update
      runWithPriority(IdlePriority, () => {
        root.render(<App label="B" />);
      });

      // The next commit should only include the visible content
      expect(Scheduler).toFlushUntilNextPaint(['Visible: B']);
      expect(root).toMatchRenderedOutput(
        <>
          Visible: B
          <div hidden={true} />
        </>,
      );
    });

    // The hidden content commits later
    expect(Scheduler).toHaveYielded(['Hidden: B']);
    expect(root).toMatchRenderedOutput(
      <>
        Visible: B<div hidden={true}>Hidden: B</div>
      </>,
    );
  });
});

describe(
  'regression test: does not infinite loop if `shouldYield` returns ' +
    'true after a partial tree expires',
  () => {
    let logDuringShouldYield = false;

    beforeEach(() => {
      jest.resetModules();

      jest.mock('scheduler', () => {
        const actual = require.requireActual('scheduler/unstable_mock');
        return {
          ...actual,
          unstable_shouldYield() {
            if (logDuringShouldYield) {
              actual.unstable_yieldValue('shouldYield');
            }
            return actual.unstable_shouldYield();
          },
        };
      });

      React = require('react');
      ReactNoop = require('react-noop-renderer');
      Scheduler = require('scheduler');
    });

    afterEach(() => {
      jest.mock('scheduler', () =>
        require.requireActual('scheduler/unstable_mock'),
      );
    });

    it('using public APIs to trigger real world scenario', async () => {
      // This test reproduces a case where React's Scheduler task timed out but
      // the `shouldYield` method returned true. The bug was that React fell
      // into an infinite loop, because it would enter the work loop then
      // immediately yield back to Scheduler.
      //
      // (The next test in this suite covers the same case. The difference is
      // that this test only uses public APIs, whereas the next test mocks
      // `shouldYield` to check when it is called.)
      function Text({text}) {
        return text;
      }

      function App({step}) {
        return (
          <>
            <Text text="A" />
            <TriggerErstwhileSchedulerBug />
            <Text text="B" />
            <TriggerErstwhileSchedulerBug />
            <Text text="C" />
          </>
        );
      }

      function TriggerErstwhileSchedulerBug() {
        // This triggers a once-upon-a-time bug in Scheduler that caused
        // `shouldYield` to return true even though the current task expired.
        Scheduler.unstable_advanceTime(10000);
        Scheduler.unstable_requestPaint();
        return null;
      }

      await ReactNoop.act(async () => {
        ReactNoop.render(<App />);
        expect(Scheduler).toFlushUntilNextPaint([]);
        expect(Scheduler).toFlushUntilNextPaint([]);
      });
    });

    it('mock Scheduler module to check if `shouldYield` is called', async () => {
      // This test reproduces a bug where React's Scheduler task timed out but
      // the `shouldYield` method returned true. Usually we try not to mock
      // internal methods, but I've made an exception here since the point is
      // specifically to test that React is resilient to the behavior of a
      // Scheduler API. That being said, feel free to rewrite or delete this
      // test if/when the API changes.
      function Text({text}) {
        Scheduler.unstable_yieldValue(text);
        return text;
      }

      function App({step}) {
        return (
          <>
            <Text text="A" />
            <Text text="B" />
            <Text text="C" />
          </>
        );
      }

      await ReactNoop.act(async () => {
        // Partially render the tree, then yield
        ReactNoop.render(<App />);
        expect(Scheduler).toFlushAndYieldThrough(['A']);

        // Start logging whenever shouldYield is called
        logDuringShouldYield = true;
        // Let's call it once to confirm the mock actually works
        Scheduler.unstable_shouldYield();
        expect(Scheduler).toHaveYielded(['shouldYield']);

        // Expire the task
        Scheduler.unstable_advanceTime(10000);
        // Scheduling a new update is a trick to force the expiration to kick
        // in. We don't check if a update has been starved at the beginning of
        // working on it, since there's no point — we're already working on it.
        // We only check before yielding to the main thread (to avoid starvation
        // by other main thread work) or when receiving an update (to avoid
        // starvation by incoming updates).
        ReactNoop.render(<App />);

        // Because the render expired, React should finish the tree without
        // consulting `shouldYield` again
        expect(Scheduler).toFlushExpired(['B', 'C']);
      });
    });
  },
);
