/**
* 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 transition
startTransition(() => {
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 transition
startTransition(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 update
startTransition(() => {
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 state
expect(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 state
expect(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 enableLegacyCache
it('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');
});
});