'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) {
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 {
caches[caches.length - 1].resolve(text);
}
}
it('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');
});
it(
'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>
</>
);
}
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>
</>,
);
await act(() => {
update('B');
});
assertLog([
'B label (loading...)',
'A content',
'B label',
'Suspend! [B content]',
'Loading...',
]);
expect(root).toMatchRenderedOutput(
<>
B label (loading...)<div>A content</div>
</>,
);
await act(() => {
update('C');
});
assertLog([
'C label (loading...)',
'A content',
'C label',
'Suspend! [C content]',
'Loading...',
]);
expect(root).toMatchRenderedOutput(
<>
C label (loading...)<div>A content</div>
</>,
);
await act(() => {
resolveText('B content');
});
assertLog([
'C label',
'Suspend! [C content]',
'Loading...',
]);
expect(root).toMatchRenderedOutput(
<>
C label (loading...)<div>A content</div>
</>,
);
await act(() => {
resolveText('C content');
});
assertLog(['C label', 'C content']);
expect(root).toMatchRenderedOutput(
<>
C label<div>C content</div>
</>,
);
},
);
it(
'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>
</>
);
}
}
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>
</>,
);
await act(() => {
update('B');
});
assertLog([
'B label (loading...)',
'A content',
'B label',
'Suspend! [B content]',
'Loading...',
]);
expect(root).toMatchRenderedOutput(
<>
B label (loading...)<div>A content</div>
</>,
);
await act(() => {
update('C');
});
assertLog([
'C label (loading...)',
'A content',
'C label',
'Suspend! [C content]',
'Loading...',
]);
expect(root).toMatchRenderedOutput(
<>
C label (loading...)<div>A content</div>
</>,
);
await act(() => {
resolveText('B content');
});
assertLog([
'C label',
'Suspend! [C content]',
'Loading...',
]);
expect(root).toMatchRenderedOutput(
<>
C label (loading...)<div>A content</div>
</>,
);
await act(() => {
resolveText('C content');
});
assertLog(['C label', 'C content']);
expect(root).toMatchRenderedOutput(
<>
C label<div>C content</div>
</>,
);
},
);
it(
'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;
return (
<>
<Suspense fallback={<Text text="Loading..." />}>
{showA ? <AsyncText text="A" /> : null}
{showB ? <AsyncText text="B" /> : null}
{showC ? <AsyncText text="C" /> : null}
</Suspense>
</>
);
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(<App />);
});
assertLog([]);
expect(root).toMatchRenderedOutput(null);
await act(() => {
startTransition(() => {
setShowA(true);
});
});
assertLog(['Suspend! [A]', 'Loading...']);
expect(root).toMatchRenderedOutput(null);
await act(() => {
startTransition(() => {
setShowA(false);
setShowB(true);
});
});
assertLog(['Suspend! [B]', 'Loading...']);
expect(root).toMatchRenderedOutput(null);
await act(() => {
startTransition(() => {
setShowB(false);
setShowC(true);
});
});
assertLog(['Suspend! [C]', 'Loading...']);
expect(root).toMatchRenderedOutput(null);
await act(() => {
startTransition(() => {
resolveText('B');
});
});
assertLog(['Suspend! [C]', 'Loading...']);
expect(root).toMatchRenderedOutput(null);
await act(() => {
startTransition(() => {
resolveText('A');
});
});
assertLog(['Suspend! [C]', 'Loading...']);
expect(root).toMatchRenderedOutput(null);
await act(() => {
startTransition(() => {
resolveText('C');
});
});
assertLog(['C']);
expect(root).toMatchRenderedOutput('C');
},
);
it('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 () => {
startTransition(() => {
root.render(
<>
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Async" />
</Suspense>
<Text text="After Suspense" />
<Text text="Sibling" />
</>,
);
});
await waitFor([
'Suspend! [Async]',
'Loading...',
'After Suspense',
]);
startTransition(async () => {
root.render(
<>
<Suspense fallback={<Text text="Loading..." />} />
<Text text="Updated" />
</>,
);
});
});
assertLog(['Updated']);
expect(root).toMatchRenderedOutput('Updated');
});
it(
"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');
startTransition(() => {
setShouldSuspend(true);
});
await waitFor(['A']);
React.startTransition(() => {
setShouldHideInParent(true);
});
await waitFor([
'shouldHideInParent: false',
'Suspend! [Async]',
'Loading...',
'B',
]);
expect(root).toMatchRenderedOutput('ABC');
await waitForPaint(['shouldHideInParent: true', '(empty)']);
expect(root).toMatchRenderedOutput('A(empty)BC');
await waitForAll([
'A',
'shouldHideInParent: true',
'(empty)',
'B',
'C',
]);
expect(root).toMatchRenderedOutput('A(empty)BC');
});
},
);
it(
'interrupt a refresh transition when something suspends and a parent ' +
'component received an interleaved update after its queue was processed',
async () => {
function App({shouldSuspend, step}) {
return (
<>
<Text text={`A${step}`} />
<Suspense fallback={<Text text="Loading..." />}>
{shouldSuspend ? <AsyncText text="Async" /> : 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 () => {
startTransition(() => {
root.render(<App shouldSuspend={true} step={1} />);
});
await waitFor(['A1']);
startTransition(() => {
root.render(<App shouldSuspend={false} step={2} />);
});
await waitFor([
'Suspend! [Async]',
'Loading...',
'B1',
]);
expect(root).toMatchRenderedOutput('A0B0C0');
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 />);
});
assertLog(['Transition pri: 0', 'Normal pri: 0', 'Commit']);
expect(root).toMatchRenderedOutput('Transition pri: 0, Normal pri: 0');
await act(() => {
updateTransitionPri();
updateNormalPri();
});
assertLog([
'Transition pri: 0',
'Normal pri: 1',
'Commit',
'Transition pri: 1',
'Normal pri: 1',
'Commit',
]);
expect(root).toMatchRenderedOutput('Transition pri: 1, Normal pri: 1');
});
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 />);
});
assertLog(['(empty)', 'Normal pri: 0', 'Commit']);
expect(root).toMatchRenderedOutput('(empty), Normal pri: 0');
await act(() => {
updateTransitionPri();
});
assertLog([
'Suspend! [Async]',
'Normal pri: 0',
'Loading...',
]);
expect(root).toMatchRenderedOutput('(empty), Normal pri: 0');
await act(async () => {
await resolveText('Async');
updateNormalPri();
});
assertLog([
'(empty)',
'Normal pri: 1',
'Commit',
'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([
'Transition pri: 1',
]);
updateNormalPri();
});
assertLog([
'Normal pri: 0',
'Commit',
'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');
});
});