/**
* 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
* @jest-environment node
*/
'use strict';
let React;
let ReactNoop;
let Scheduler;
let act;
let NormalPriority;
let IdlePriority;
let runWithPriority;
let startTransition;
let waitForAll;
let waitForPaint;
let assertLog;
let waitFor;
describe('ReactSchedulerIntegration', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactNoop = require('react-noop-renderer');
Scheduler = require('scheduler');
act = require('internal-test-utils').act;
NormalPriority = Scheduler.unstable_NormalPriority;
IdlePriority = Scheduler.unstable_IdlePriority;
runWithPriority = Scheduler.unstable_runWithPriority;
startTransition = React.startTransition;
const InternalTestUtils = require('internal-test-utils');
waitForAll = InternalTestUtils.waitForAll;
waitForPaint = InternalTestUtils.waitForPaint;
assertLog = InternalTestUtils.assertLog;
waitFor = InternalTestUtils.waitFor;
});
// 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('passive effects are called before Normal-pri scheduled in layout effects', async () => {
const {useEffect, useLayoutEffect} = React;
function Effects({step}) {
useLayoutEffect(() => {
Scheduler.log('Layout Effect');
Scheduler.unstable_scheduleCallback(NormalPriority, () =>
Scheduler.log('Scheduled Normal Callback from Layout Effect'),
);
});
useEffect(() => {
Scheduler.log('Passive Effect');
});
return null;
}
function CleanupEffect() {
useLayoutEffect(() => () => {
Scheduler.log('Cleanup Layout Effect');
Scheduler.unstable_scheduleCallback(NormalPriority, () =>
Scheduler.log('Scheduled Normal Callback from Cleanup Layout Effect'),
);
});
return null;
}
await act(() => {
ReactNoop.render(<CleanupEffect />);
});
assertLog([]);
await act(() => {
ReactNoop.render(<Effects />);
});
assertLog([
'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('requests a paint after committing', async () => {
const scheduleCallback = Scheduler.unstable_scheduleCallback;
const root = ReactNoop.createRoot();
root.render('Initial');
await waitForAll([]);
expect(root).toMatchRenderedOutput('Initial');
scheduleCallback(NormalPriority, () => Scheduler.log('A'));
scheduleCallback(NormalPriority, () => Scheduler.log('B'));
scheduleCallback(NormalPriority, () => Scheduler.log('C'));
// Schedule a React render. React will request a paint after committing it.
React.startTransition(() => {
root.render('Update');
});
// Perform just a little bit of work. By now, the React task will have
// already been scheduled, behind A, B, and C.
await waitFor(['A']);
// Schedule some additional tasks. These won't fire until after the React
// update has finished.
scheduleCallback(NormalPriority, () => Scheduler.log('D'));
scheduleCallback(NormalPriority, () => Scheduler.log('E'));
// Flush everything up to the next paint. Should yield after the
// React commit.
await waitForPaint(['B', 'C']);
expect(root).toMatchRenderedOutput('Update');
// Now flush the rest of the work.
await waitForAll(['D', 'E']);
});
// @gate www
it('idle updates are not blocked by offscreen work', async () => {
function Text({text}) {
Scheduler.log(text);
return text;
}
function App({label}) {
return (
<>
<Text text={`Visible: ` + label} />
<LegacyHiddenDiv mode="hidden">
<Text text={`Hidden: ` + label} />
</LegacyHiddenDiv>
</>
);
}
const root = ReactNoop.createRoot();
await act(async () => {
root.render(<App label="A" />);
// Commit the visible content
await waitForPaint(['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
await waitForPaint(['Visible: B']);
expect(root).toMatchRenderedOutput(
<>
Visible: B
<div hidden={true} />
</>,
);
});
// The hidden content commits later
assertLog(['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 = jest.requireActual('scheduler/unstable_mock');
return {
...actual,
unstable_shouldYield() {
if (logDuringShouldYield) {
actual.log('shouldYield');
}
return actual.unstable_shouldYield();
},
};
});
React = require('react');
ReactNoop = require('react-noop-renderer');
Scheduler = require('scheduler');
startTransition = React.startTransition;
const InternalTestUtils = require('internal-test-utils');
waitForAll = InternalTestUtils.waitForAll;
waitForPaint = InternalTestUtils.waitForPaint;
assertLog = InternalTestUtils.assertLog;
waitFor = InternalTestUtils.waitFor;
act = InternalTestUtils.act;
});
afterEach(() => {
jest.mock('scheduler', () =>
jest.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 act(async () => {
ReactNoop.render(<App />);
await waitForPaint([]);
await waitForPaint([]);
});
});
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.log(text);
return text;
}
function App({step}) {
return (
<>
<Text text="A" />
<Text text="B" />
<Text text="C" />
</>
);
}
await act(async () => {
// Partially render the tree, then yield
startTransition(() => {
ReactNoop.render(<App />);
});
await waitFor(['A']);
// Start logging whenever shouldYield is called
logDuringShouldYield = true;
// Let's call it once to confirm the mock actually works
await waitFor(['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).
startTransition(() => {
ReactNoop.render(<App />);
});
// Because the render expired, React should finish the tree without
// consulting `shouldYield` again
await waitFor(['B', 'C']);
});
});
},
);
describe('`act` bypasses Scheduler methods completely,', () => {
let infiniteLoopGuard;
beforeEach(() => {
jest.resetModules();
infiniteLoopGuard = 0;
jest.mock('scheduler', () => {
const actual = jest.requireActual('scheduler/unstable_mock');
return {
...actual,
unstable_shouldYield() {
// This simulates a bug report where `shouldYield` returns true in a
// unit testing environment. Because `act` will keep working until
// there's no more work left, it would fall into an infinite loop.
// The fix is that when performing work inside `act`, we should bypass
// `shouldYield` completely, because we can't trust it to be correct.
if (infiniteLoopGuard++ > 100) {
throw new Error('Detected an infinite loop');
}
return true;
},
};
});
React = require('react');
ReactNoop = require('react-noop-renderer');
startTransition = React.startTransition;
});
afterEach(() => {
jest.mock('scheduler', () => jest.requireActual('scheduler/unstable_mock'));
});
// @gate __DEV__
it('inside `act`, does not call `shouldYield`, even during a concurrent render', async () => {
function App() {
return (
<>
<div>A</div>
<div>B</div>
<div>C</div>
</>
);
}
const root = ReactNoop.createRoot();
const publicAct = React.unstable_act;
const prevIsReactActEnvironment = global.IS_REACT_ACT_ENVIRONMENT;
try {
global.IS_REACT_ACT_ENVIRONMENT = true;
await publicAct(async () => {
startTransition(() => root.render(<App />));
});
} finally {
global.IS_REACT_ACT_ENVIRONMENT = prevIsReactActEnvironment;
}
expect(root).toMatchRenderedOutput(
<>
<div>A</div>
<div>B</div>
<div>C</div>
</>,
);
});
});