/*** 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 Suspense;
let useState;
let useLayoutEffect;
let useTransition;
let startTransition;
let act;
let getCacheForType;
let waitForAll;
let waitFor;
let waitForPaint;
let assertLog;
let caches;
let seededCache;
describe('ReactTransition', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactNoop = require('react-noop-renderer');
Scheduler = require('scheduler');
useState = React.useState;
useLayoutEffect = React.useLayoutEffect;
useTransition = React.useTransition;
Suspense = React.Suspense;
startTransition = React.startTransition;
getCacheForType = React.unstable_getCacheForType;
act = require('internal-test-utils').act;
const InternalTestUtils = require('internal-test-utils');
waitForAll = InternalTestUtils.waitForAll;
waitFor = InternalTestUtils.waitFor;
waitForPaint = InternalTestUtils.waitForPaint;
assertLog = InternalTestUtils.assertLog;
caches = [];
seededCache = null;
});function createTextCache() {
if (seededCache !== null) {
// Trick to seed a cache before it exists.
// TODO: Need a built-in API to seed data before the initial render (i.e.
// not a refresh because nothing has mounted yet).
const cache = seededCache;
seededCache = null;
return cache;
}const data = new Map();
const version = caches.length + 1;
const cache = {
version,
data,
resolve(text) {
const record = data.get(text);
if (record === undefined) {
const newRecord = {
status: 'resolved',
value: text,
};data.set(text, newRecord);
} else if (record.status === 'pending') {
const thenable = record.value;
record.status = 'resolved';
record.value = text;
thenable.pings.forEach(t => t());
}},reject(text, error) {
const record = data.get(text);
if (record === undefined) {
const newRecord = {
status: 'rejected',
value: error,
};data.set(text, newRecord);
} else if (record.status === 'pending') {
const thenable = record.value;
record.status = 'rejected';
record.value = error;
thenable.pings.forEach(t => t());
}},};caches.push(cache);
return cache;
}function readText(text) {
const textCache = getCacheForType(createTextCache);
const record = textCache.data.get(text);
if (record !== undefined) {
switch (record.status) {
case 'pending':
Scheduler.log(`Suspend! [${text}]`);
throw record.value;
case 'rejected':
Scheduler.log(`Error! [${text}]`);
throw record.value;
case 'resolved':
return textCache.version;
}} else {
Scheduler.log(`Suspend! [${text}]`);
const thenable = {
pings: [],then(resolve) {
if (newRecord.status === 'pending') {
thenable.pings.push(resolve);
} else {
Promise.resolve().then(() => resolve(newRecord.value));
}},};const newRecord = {
status: 'pending',
value: thenable,
};textCache.data.set(text, newRecord);
throw thenable;
}}function Text({text}) {
Scheduler.log(text);
return text;
}function AsyncText({text}) {
readText(text);
Scheduler.log(text);
return text;
}function seedNextTextCache(text) {
if (seededCache === null) {
seededCache = createTextCache();
}seededCache.resolve(text);
}function resolveText(text) {
if (caches.length === 0) {
throw Error('Cache does not exist.');
} else {
// Resolve the most recently created cache. An older cache can by
// resolved with `caches[index].resolve(text)`.
caches[caches.length - 1].resolve(text);
}}// @gate enableLegacyCache
test('isPending works even if called from outside an input event', async () => {
let start;
function App() {
const [show, setShow] = useState(false);
const [isPending, _start] = useTransition();
start = () => _start(() => setShow(true));
return (
<Suspense fallback={<Text text="Loading..." />}>
{isPending ? <Text text="Pending..." /> : null}
{show ? <AsyncText text="Async" /> : <Text text="(empty)" />}
</Suspense>
);}const root = ReactNoop.createRoot();
await act(() => {root.render(<App />);
});assertLog(['(empty)']);
expect(root).toMatchRenderedOutput('(empty)');
await act(async () => {start();await waitForAll([
'Pending...','(empty)','Suspend! [Async]',
'Loading...',
]);expect(root).toMatchRenderedOutput('Pending...(empty)');
await resolveText('Async');});assertLog(['Async']);
expect(root).toMatchRenderedOutput('Async');
});// @gate enableLegacyCache
test(
'when multiple transitions update the same queue, only the most recent ' +
'one is allowed to finish (no intermediate states)',
async () => {
let update;
function App() {
const [isContentPending, startContentChange] = useTransition();
const [label, setLabel] = useState('A');
const [contents, setContents] = useState('A');
update = value => {
ReactNoop.discreteUpdates(() => {
setLabel(value);
startContentChange(() => {
setContents(value);
});});};return (
<><Text
text={
label + ' label' + (isContentPending ? ' (loading...)' : '')
}/><div>
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={contents + ' content'} />
</Suspense>
</div>
</>
);}// Initial render
const root = ReactNoop.createRoot();
await act(() => {
seedNextTextCache('A content');
root.render(<App />);
});assertLog(['A label', 'A content']);
expect(root).toMatchRenderedOutput(
<>A label<div>A content</div>
</>,
);// Switch to B
await act(() => {
update('B');
});assertLog([
// Commit pending state
'B label (loading...)',
'A content',
// Attempt to render B, but it suspends
'B label',
'Suspend! [B content]',
'Loading...',
]);// This is a refresh transition so it shouldn't show a fallback
expect(root).toMatchRenderedOutput(
<>B label (loading...)<div>A content</div>
</>,
);// Before B finishes loading, switch to C
await act(() => {
update('C');
});assertLog([
// Commit pending state
'C label (loading...)',
'A content',
// Attempt to render C, but it suspends
'C label',
'Suspend! [C content]',
'Loading...',
]);expect(root).toMatchRenderedOutput(
<>C label (loading...)<div>A content</div>
</>,
);// Finish loading B. But we're not allowed to render B because it's
// entangled with C. So we're still pending.
await act(() => {
resolveText('B content');
});assertLog([
// Attempt to render C, but it suspends
'C label',
'Suspend! [C content]',
'Loading...',
]);expect(root).toMatchRenderedOutput(
<>C label (loading...)<div>A content</div>
</>,
);// Now finish loading C. This is the terminal update, so it can finish.
await act(() => {
resolveText('C content');
});assertLog(['C label', 'C content']);
expect(root).toMatchRenderedOutput(
<>C label<div>C content</div>
</>,
);},);// Same as previous test, but for class update queue.
// @gate enableLegacyCache
test(
'when multiple transitions update the same queue, only the most recent ' +
'one is allowed to finish (no intermediate states) (classes)',
async () => {
let update;
class App extends React.Component {
state = {label: 'A',contents: 'A',};
render() {
update = value => {
ReactNoop.discreteUpdates(() => {
this.setState({label: value});
startTransition(() => {
this.setState({contents: value});
});});};const label = this.state.label;
const contents = this.state.contents;
const isContentPending = label !== contents;
return (
<><Text
text={
label + ' label' + (isContentPending ? ' (loading...)' : '')
}/><div>
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={contents + ' content'} />
</Suspense>
</div>
</>
);}}// Initial render
const root = ReactNoop.createRoot();
await act(() => {
seedNextTextCache('A content');
root.render(<App />);
});assertLog(['A label', 'A content']);
expect(root).toMatchRenderedOutput(
<>A label<div>A content</div>
</>,
);// Switch to B
await act(() => {
update('B');
});assertLog([
// Commit pending state
'B label (loading...)',
'A content',
// Attempt to render B, but it suspends
'B label',
'Suspend! [B content]',
'Loading...',
]);// This is a refresh transition so it shouldn't show a fallback
expect(root).toMatchRenderedOutput(
<>B label (loading...)<div>A content</div>
</>,
);// Before B finishes loading, switch to C
await act(() => {
update('C');
});assertLog([
// Commit pending state
'C label (loading...)',
'A content',
// Attempt to render C, but it suspends
'C label',
'Suspend! [C content]',
'Loading...',
]);expect(root).toMatchRenderedOutput(
<>C label (loading...)<div>A content</div>
</>,
);// Finish loading B. But we're not allowed to render B because it's
// entangled with C. So we're still pending.
await act(() => {
resolveText('B content');
});assertLog([
// Attempt to render C, but it suspends
'C label',
'Suspend! [C content]',
'Loading...',
]);expect(root).toMatchRenderedOutput(
<>C label (loading...)<div>A content</div>
</>,
);// Now finish loading C. This is the terminal update, so it can finish.
await act(() => {
resolveText('C content');
});assertLog(['C label', 'C content']);
expect(root).toMatchRenderedOutput(
<>C label<div>C content</div>
</>,
);},);// @gate enableLegacyCache
test(
'when multiple transitions update overlapping queues, all the transitions ' +
'across all the queues are entangled',
async () => {
let setShowA;
let setShowB;
let setShowC;
function App() {
const [showA, _setShowA] = useState(false);
const [showB, _setShowB] = useState(false);
const [showC, _setShowC] = useState(false);
setShowA = _setShowA;
setShowB = _setShowB;
setShowC = _setShowC;
// Only one of these children should be visible at a time. Except
// instead of being modeled as a single state, it's three separate
// states that are updated simultaneously. This may seem a bit
// contrived, but it's more common than you might think. Usually via
// a framework or indirection. For example, consider a tooltip manager
// that only shows a single tooltip at a time. Or a router that
// highlights links to the active route.
return (
<><Suspense fallback={<Text text="Loading..." />}>
{showA ? <AsyncText text="A" /> : null}
{showB ? <AsyncText text="B" /> : null}
{showC ? <AsyncText text="C" /> : null}
</Suspense>
</>
);
}// Initial render. Start with all children hidden.
const root = ReactNoop.createRoot();
await act(() => {
root.render(<App />);
});assertLog([]);
expect(root).toMatchRenderedOutput(null);
// Switch to A.
await act(() => {
startTransition(() => {
setShowA(true);
});});assertLog(['Suspend! [A]', 'Loading...']);
expect(root).toMatchRenderedOutput(null);
// Before A loads, switch to B. This should entangle A with B.
await act(() => {
startTransition(() => {
setShowA(false);
setShowB(true);
});});assertLog(['Suspend! [B]', 'Loading...']);
expect(root).toMatchRenderedOutput(null);
// Before A or B loads, switch to C. This should entangle C with B, and
// transitively entangle C with A.
await act(() => {
startTransition(() => {
setShowB(false);
setShowC(true);
});});assertLog(['Suspend! [C]', 'Loading...']);
expect(root).toMatchRenderedOutput(null);
// Now the data starts resolving out of order.
// First resolve B. This will attempt to render C, since everything is
// entangled.
await act(() => {
startTransition(() => {
resolveText('B');
});});assertLog(['Suspend! [C]', 'Loading...']);
expect(root).toMatchRenderedOutput(null);
// Now resolve A. Again, this will attempt to render C, since everything
// is entangled.
await act(() => {
startTransition(() => {
resolveText('A');
});});assertLog(['Suspend! [C]', 'Loading...']);
expect(root).toMatchRenderedOutput(null);
// Finally, resolve C. This time we can finish.
await act(() => {
startTransition(() => {
resolveText('C');
});});assertLog(['C']);
expect(root).toMatchRenderedOutput('C');
},);// @gate enableLegacyCache
test('interrupt a refresh transition if a new transition is scheduled', async () => {
const root = ReactNoop.createRoot();
await act(() => {
root.render(
<><Suspense fallback={<Text text="Loading..." />} />
<Text text="Initial" />
</>,
);});assertLog(['Initial']);
expect(root).toMatchRenderedOutput('Initial');
await act(async () => {// Start a refresh transitionstartTransition(() => {root.render(
<><Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Async" /></Suspense><Text text="After Suspense" /><Text text="Sibling" /></>,);});// Partially render it.
await waitFor([
// Once we the update suspends, we know it's a refresh transition,// because the Suspense boundary has already mounted.'Suspend! [Async]',
'Loading...',
'After Suspense',]);// Schedule a new transitionstartTransition(async () => {root.render(
<><Suspense fallback={<Text text="Loading..." />} />
<Text text="Updated" /></>,);});});// Because the first one is going to suspend regardless, we should
// immediately switch to rendering the new transition.
assertLog(['Updated']);
expect(root).toMatchRenderedOutput('Updated');
});// @gate enableLegacyCache
test(
"interrupt a refresh transition when something suspends and we've " +
'already bailed out on another transition in a parent',
async () => {
let setShouldSuspend;
function Parent({children}) {
const [shouldHideInParent, _setShouldHideInParent] = useState(false);
setShouldHideInParent = _setShouldHideInParent;
Scheduler.log('shouldHideInParent: ' + shouldHideInParent);
if (shouldHideInParent) {
return <Text text="(empty)" />;
}return children;
}let setShouldHideInParent;
function App() {
const [shouldSuspend, _setShouldSuspend] = useState(false);
setShouldSuspend = _setShouldSuspend;
return (
<><Text text="A" />
<Parent>
<Suspense fallback={<Text text="Loading..." />}>
{shouldSuspend ? <AsyncText text="Async" /> : null}
</Suspense>
</Parent>
<Text text="B" />
<Text text="C" />
</>
);}const root = ReactNoop.createRoot();
await act(async () => {root.render(<App />);
await waitForAll(['A', 'shouldHideInParent: false', 'B', 'C']);
expect(root).toMatchRenderedOutput('ABC');
// Schedule an updatestartTransition(() => {setShouldSuspend(true);});// Now we need to trigger schedule another transition in a different// lane from the first one. At the time this was written, all transitions are worked on
// simultaneously, unless a transition was already in progress when a// new one was scheduled. So, partially render the first transition.
await waitFor(['A']);
// Now schedule a second transition. We won't interrupt the first one.
React.startTransition(() => {
setShouldHideInParent(true);});// Continue rendering the first transition.
await waitFor([
'shouldHideInParent: false','Suspend! [Async]',
'Loading...',
'B',]);// Should not have committed loading stateexpect(root).toMatchRenderedOutput('ABC');
// At this point, we've processed the parent update queue, so we know// that it has a pending update from the second transition, even though// we skipped it during this render. And we know this is a refresh
// transition, because we had to render a loading state. So the next
// time we re-enter the work loop (we don't interrupt immediately, we// just wait for the next time slice), we should throw out the// suspended first transition and try the second one.
await waitForPaint(['shouldHideInParent: true', '(empty)']);
expect(root).toMatchRenderedOutput('A(empty)BC');
// Since the two transitions are not entangled, we then later go back// and finish retry the first transition. Not really relevant to this
// test but I'll assert the result anyway.
await waitForAll([
'A','shouldHideInParent: true','(empty)','B','C',]);
expect(root).toMatchRenderedOutput('A(empty)BC');
});},);// @gate enableLegacyCache
test(
'interrupt a refresh transition when something suspends and a parent ' +
'component received an interleaved update after its queue was processed',
async () => {
// Title is confusing so I'll try to explain further: This is similar to
// the previous test, except instead of skipped over a transition update
// in a parent, the parent receives an interleaved update *after* its
// begin phase has already finished.
function App({shouldSuspend, step}) {
return (
<><Text text={`A${step}`} />
<Suspense fallback={<Text text="Loading..." />}>{shouldSuspend ? <AsyncText text="Async" ms={2000} /> : null}</Suspense><Text text={`B${step}`} />
<Text text={`C${step}`} />
</>);}const root = ReactNoop.createRoot();await act(() => {root.render(<App shouldSuspend={false} step={0} />);});assertLog(['A0', 'B0', 'C0']);expect(root).toMatchRenderedOutput('A0B0C0');await act(async () => {// This update will suspend.startTransition(() => {root.render(<App shouldSuspend={true} step={1} />);});// Flush past the root, but stop before the async component.await waitFor(['A1']);// Schedule another transition on the root, which already completed.startTransition(() => {root.render(<App shouldSuspend={false} step={2} />);});// We'll keep working on the first update.await waitFor([// Now the async component suspends'Suspend! [Async]','Loading...','B1',]);// Should not have committed loading stateexpect(root).toMatchRenderedOutput('A0B0C0');// After suspending, should abort the first update and switch to the// second update. So, C1 should not appear in the log.// TODO: This should work even if React does not yield to the main// thread. Should use same mechanism as selective hydration to interrupt// the render before the end of the current slice of work.await waitForAll(['A2', 'B2', 'C2']);expect(root).toMatchRenderedOutput('A2B2C2');});},);it('should render normal pri updates scheduled after transitions before transitions', async () => {let updateTransitionPri;let updateNormalPri;function App() {const [normalPri, setNormalPri] = useState(0);const [transitionPri, setTransitionPri] = useState(0);updateTransitionPri = () =>startTransition(() => setTransitionPri(n => n + 1));updateNormalPri = () => setNormalPri(n => n + 1);useLayoutEffect(() => {Scheduler.log('Commit');});return (<Suspense fallback={<Text text="Loading..." />}><Text text={'Transition pri: ' + transitionPri} />{', '}<Text text={'Normal pri: ' + normalPri} /></Suspense>);}const root = ReactNoop.createRoot();await act(() => {root.render(<App />);});// Initial render.assertLog(['Transition pri: 0', 'Normal pri: 0', 'Commit']);expect(root).toMatchRenderedOutput('Transition pri: 0, Normal pri: 0');await act(() => {updateTransitionPri();updateNormalPri();});assertLog([// Normal update first.'Transition pri: 0','Normal pri: 1','Commit',// Then transition update.'Transition pri: 1','Normal pri: 1','Commit',]);expect(root).toMatchRenderedOutput('Transition pri: 1, Normal pri: 1');});// @gate enableLegacyCacheit('should render normal pri updates before transition suspense retries', async () => {let updateTransitionPri;let updateNormalPri;function App() {const [transitionPri, setTransitionPri] = useState(false);const [normalPri, setNormalPri] = useState(0);updateTransitionPri = () => startTransition(() => setTransitionPri(true));updateNormalPri = () => setNormalPri(n => n + 1);useLayoutEffect(() => {Scheduler.log('Commit');});return (<Suspense fallback={<Text text="Loading..." />}>{transitionPri ? <AsyncText text="Async" /> : <Text text="(empty)" />}{', '}<Text text={'Normal pri: ' + normalPri} /></Suspense>);}const root = ReactNoop.createRoot();await act(() => {root.render(<App />);});// Initial render.assertLog(['(empty)', 'Normal pri: 0', 'Commit']);expect(root).toMatchRenderedOutput('(empty), Normal pri: 0');await act(() => {updateTransitionPri();});assertLog([// Suspend.'Suspend! [Async]','Loading...',]);expect(root).toMatchRenderedOutput('(empty), Normal pri: 0');await act(async () => {await resolveText('Async');updateNormalPri();});assertLog([// Normal pri update.'(empty)','Normal pri: 1','Commit',// Promise resolved, retry flushed.'Async','Normal pri: 1','Commit',]);expect(root).toMatchRenderedOutput('Async, Normal pri: 1');});it('should not interrupt transitions with normal pri updates', async () => {let updateNormalPri;let updateTransitionPri;function App() {const [transitionPri, setTransitionPri] = useState(0);const [normalPri, setNormalPri] = useState(0);updateTransitionPri = () =>startTransition(() => setTransitionPri(n => n + 1));updateNormalPri = () => setNormalPri(n => n + 1);useLayoutEffect(() => {Scheduler.log('Commit');});return (<><Text text={'Transition pri: ' + transitionPri} />{', '}<Text text={'Normal pri: ' + normalPri} /></>);}const root = ReactNoop.createRoot();await act(() => {root.render(<App />);});assertLog(['Transition pri: 0', 'Normal pri: 0', 'Commit']);expect(root).toMatchRenderedOutput('Transition pri: 0, Normal pri: 0');await act(async () => {updateTransitionPri();await waitFor([// Start transition update.'Transition pri: 1',]);// Schedule normal pri update during transition update.// This should not interrupt.updateNormalPri();});if (gate(flags => flags.enableUnifiedSyncLane)) {assertLog(['Normal pri: 0','Commit',// Normal pri update.'Transition pri: 1','Normal pri: 1','Commit',]);} else {assertLog([// Finish transition update.'Normal pri: 0','Commit',// Normal pri update.'Transition pri: 1','Normal pri: 1','Commit',]);}expect(root).toMatchRenderedOutput('Transition pri: 1, Normal pri: 1');});it('tracks two pending flags for nested startTransition (#26226)', async () => {let update;function App() {const [isPendingA, startTransitionA] = useTransition();const [isPendingB, startTransitionB] = useTransition();const [state, setState] = useState(0);update = function () {startTransitionA(() => {startTransitionB(() => {setState(1);});});};return (<><Text text={state} />{', '}<Text text={'A ' + isPendingA} />{', '}<Text text={'B ' + isPendingB} /></>);}const root = ReactNoop.createRoot();await act(async () => {root.render(<App />);});assertLog([0, 'A false', 'B false']);expect(root).toMatchRenderedOutput('0, A false, B false');await act(async () => {update();});assertLog([0, 'A true', 'B true', 1, 'A false', 'B false']);expect(root).toMatchRenderedOutput('1, A false, B false');});});