let React;
let ReactTestRenderer;
let ReactFeatureFlags;
let Scheduler;
let ReactCache;
let Suspense;
let act;
let TextResource;
let textResourceShouldFail;
describe('ReactSuspense', () => {
beforeEach(() => {
jest.resetModules();
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
React = require('react');
ReactTestRenderer = require('react-test-renderer');
act = require('jest-react').act;
Scheduler = require('scheduler');
ReactCache = require('react-cache');
Suspense = React.Suspense;
TextResource = ReactCache.unstable_createResource(
([text, ms = 0]) => {
let listeners = null;
let status = 'pending';
let value = null;
return {
then(resolve, reject) {
switch (status) {
case 'pending': {
if (listeners === null) {
listeners = [{resolve, reject}];
setTimeout(() => {
if (textResourceShouldFail) {
Scheduler.unstable_yieldValue(
`Promise rejected [${text}]`,
);
status = 'rejected';
value = new Error('Failed to load: ' + text);
listeners.forEach(listener => listener.reject(value));
} else {
Scheduler.unstable_yieldValue(
`Promise resolved [${text}]`,
);
status = 'resolved';
value = text;
listeners.forEach(listener => listener.resolve(value));
}
}, ms);
} else {
listeners.push({resolve, reject});
}
break;
}
case 'resolved': {
resolve(value);
break;
}
case 'rejected': {
reject(value);
break;
}
}
},
};
},
([text, ms]) => text,
);
textResourceShouldFail = false;
});
function Text(props) {
Scheduler.unstable_yieldValue(props.text);
return props.text;
}
function AsyncText(props) {
const text = props.text;
try {
TextResource.read([props.text, props.ms]);
Scheduler.unstable_yieldValue(text);
return text;
} catch (promise) {
if (typeof promise.then === 'function') {
Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
} else {
Scheduler.unstable_yieldValue(`Error! [${text}]`);
}
throw promise;
}
}
it('suspends rendering and continues later', () => {
function Bar(props) {
Scheduler.unstable_yieldValue('Bar');
return props.children;
}
function Foo({renderBar}) {
Scheduler.unstable_yieldValue('Foo');
return (
<Suspense fallback={<Text text="Loading..." />}>
{renderBar ? (
<Bar>
<AsyncText text="A" ms={100} />
<Text text="B" />
</Bar>
) : null}
</Suspense>
);
}
const root = ReactTestRenderer.create(<Foo />, {
unstable_isConcurrent: true,
});
expect(Scheduler).toFlushAndYield(['Foo']);
expect(root).toMatchRenderedOutput(null);
if (gate(flags => flags.enableSyncDefaultUpdates)) {
React.startTransition(() => {
root.update(<Foo renderBar={true} />);
});
} else {
root.update(<Foo renderBar={true} />);
}
expect(Scheduler).toFlushAndYield([
'Foo',
'Bar',
'Suspend! [A]',
'B',
'Loading...',
]);
expect(root).toMatchRenderedOutput(null);
jest.advanceTimersByTime(50);
expect(Scheduler).toFlushWithoutYielding();
expect(root).toMatchRenderedOutput(null);
jest.advanceTimersByTime(50);
expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
expect(Scheduler).toFlushAndYield(['Foo', 'Bar', 'A', 'B']);
expect(root).toMatchRenderedOutput('AB');
});
it('suspends siblings and later recovers each independently', () => {
const root = ReactTestRenderer.create(
<>
<Suspense fallback={<Text text="Loading A..." />}>
<AsyncText text="A" ms={5000} />
</Suspense>
<Suspense fallback={<Text text="Loading B..." />}>
<AsyncText text="B" ms={6000} />
</Suspense>
</>,
{
unstable_isConcurrent: true,
},
);
expect(Scheduler).toFlushAndYield([
'Suspend! [A]',
'Loading A...',
'Suspend! [B]',
'Loading B...',
]);
expect(root).toMatchRenderedOutput('Loading A...Loading B...');
jest.advanceTimersByTime(5000);
expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
expect(Scheduler).toFlushAndYield(['A']);
expect(root).toMatchRenderedOutput('ALoading B...');
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
expect(Scheduler).toFlushAndYield(['B']);
expect(root).toMatchRenderedOutput('AB');
});
it('interrupts current render if promise resolves before current render phase', () => {
let didResolve = false;
const listeners = [];
const thenable = {
then(resolve) {
if (!didResolve) {
listeners.push(resolve);
} else {
resolve();
}
},
};
function resolveThenable() {
didResolve = true;
listeners.forEach(l => l());
}
function Async() {
if (!didResolve) {
Scheduler.unstable_yieldValue('Suspend!');
throw thenable;
}
Scheduler.unstable_yieldValue('Async');
return 'Async';
}
const root = ReactTestRenderer.create(
<>
<Suspense fallback={<Text text="Loading..." />} />
<Text text="Initial" />
</>,
{
unstable_isConcurrent: true,
},
);
expect(Scheduler).toFlushAndYield(['Initial']);
expect(root).toMatchRenderedOutput('Initial');
if (gate(flags => flags.enableSyncDefaultUpdates)) {
React.startTransition(() => {
root.update(
<>
<Suspense fallback={<Text text="Loading..." />}>
<Async />
</Suspense>
<Text text="After Suspense" />
<Text text="Sibling" />
</>,
);
});
} else {
root.update(
<>
<Suspense fallback={<Text text="Loading..." />}>
<Async />
</Suspense>
<Text text="After Suspense" />
<Text text="Sibling" />
</>,
);
}
expect(Scheduler).toFlushAndYieldThrough([
'Suspend!',
'Loading...',
'After Suspense',
]);
resolveThenable();
expect(Scheduler).toHaveYielded([]);
expect(root).toMatchRenderedOutput('Initial');
expect(Scheduler).toFlushAndYield([
'Async',
'After Suspense',
'Sibling',
]);
expect(root).toMatchRenderedOutput('AsyncAfter SuspenseSibling');
});
it('throttles fallback committing globally', () => {
function Foo() {
Scheduler.unstable_yieldValue('Foo');
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="A" ms={200} />
<Suspense fallback={<Text text="Loading more..." />}>
<AsyncText text="B" ms={300} />
</Suspense>
</Suspense>
);
}
jest.advanceTimersByTime(600);
Scheduler.unstable_advanceTime(600);
const root = ReactTestRenderer.create(<Foo />, {
unstable_isConcurrent: true,
});
expect(Scheduler).toFlushAndYield([
'Foo',
'Suspend! [A]',
'Suspend! [B]',
'Loading more...',
'Loading...',
]);
expect(root).toMatchRenderedOutput('Loading...');
jest.advanceTimersByTime(200);
Scheduler.unstable_advanceTime(200);
expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
expect(Scheduler).toFlushAndYield(['A', 'Suspend! [B]', 'Loading more...']);
expect(root).toMatchRenderedOutput('Loading...');
jest.advanceTimersByTime(100);
Scheduler.unstable_advanceTime(100);
expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
expect(root).toMatchRenderedOutput('Loading...');
expect(Scheduler).toFlushAndYield(['A', 'B']);
expect(root).toMatchRenderedOutput('AB');
});
it('does not throttle fallback committing for too long', () => {
function Foo() {
Scheduler.unstable_yieldValue('Foo');
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="A" ms={200} />
<Suspense fallback={<Text text="Loading more..." />}>
<AsyncText text="B" ms={1200} />
</Suspense>
</Suspense>
);
}
jest.advanceTimersByTime(600);
Scheduler.unstable_advanceTime(600);
const root = ReactTestRenderer.create(<Foo />, {
unstable_isConcurrent: true,
});
expect(Scheduler).toFlushAndYield([
'Foo',
'Suspend! [A]',
'Suspend! [B]',
'Loading more...',
'Loading...',
]);
expect(root).toMatchRenderedOutput('Loading...');
jest.advanceTimersByTime(200);
Scheduler.unstable_advanceTime(200);
expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
expect(Scheduler).toFlushAndYield(['A', 'Suspend! [B]', 'Loading more...']);
expect(root).toMatchRenderedOutput('Loading...');
jest.advanceTimersByTime(500);
Scheduler.unstable_advanceTime(500);
expect(root).toMatchRenderedOutput('ALoading more...');
jest.advanceTimersByTime(500);
Scheduler.unstable_advanceTime(500);
expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
expect(Scheduler).toFlushAndYield(['B']);
expect(root).toMatchRenderedOutput('AB');
});
it(
'interrupts current render when something suspends with a ' +
"delay and we've already skipped over a lower priority update in " +
'a parent',
() => {
function interrupt() {
ReactTestRenderer.create('whatever');
}
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 = ReactTestRenderer.create(null, {
unstable_isConcurrent: true,
});
root.update(<App shouldSuspend={false} step={0} />);
expect(Scheduler).toFlushAndYield(['A0', 'B0', 'C0']);
expect(root).toMatchRenderedOutput('A0B0C0');
root.update(<App shouldSuspend={true} step={1} />);
expect(Scheduler).toFlushAndYieldThrough(['A1']);
React.startTransition(() => {
root.update(<App shouldSuspend={false} step={2} />);
});
interrupt();
expect(Scheduler).toFlushAndYieldThrough([
'A1',
'Suspend! [Async]',
'Loading...',
'B1',
]);
expect(root).toMatchRenderedOutput('A0B0C0');
expect(Scheduler).toFlushAndYield(['A2', 'B2', 'C2']);
expect(root).toMatchRenderedOutput('A2B2C2');
},
);
it('mounts a lazy class component in non-concurrent mode', async () => {
class Class extends React.Component {
componentDidMount() {
Scheduler.unstable_yieldValue('Did mount: ' + this.props.label);
}
componentDidUpdate() {
Scheduler.unstable_yieldValue('Did update: ' + this.props.label);
}
render() {
return <Text text={this.props.label} />;
}
}
async function fakeImport(result) {
return {default: result};
}
const LazyClass = React.lazy(() => fakeImport(Class));
const root = ReactTestRenderer.create(
<Suspense fallback={<Text text="Loading..." />}>
<LazyClass label="Hi" />
</Suspense>,
);
expect(Scheduler).toHaveYielded(['Loading...']);
expect(root).toMatchRenderedOutput('Loading...');
await LazyClass;
expect(Scheduler).toFlushUntilNextPaint(['Hi', 'Did mount: Hi']);
expect(root).toMatchRenderedOutput('Hi');
});
it('updates memoized child of suspense component when context updates (simple memo)', () => {
const {useContext, createContext, useState, memo} = React;
const ValueContext = createContext(null);
const MemoizedChild = memo(function MemoizedChild() {
const text = useContext(ValueContext);
try {
TextResource.read([text, 1000]);
Scheduler.unstable_yieldValue(text);
return text;
} catch (promise) {
if (typeof promise.then === 'function') {
Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
} else {
Scheduler.unstable_yieldValue(`Error! [${text}]`);
}
throw promise;
}
});
let setValue;
function App() {
const [value, _setValue] = useState('default');
setValue = _setValue;
return (
<ValueContext.Provider value={value}>
<Suspense fallback={<Text text="Loading..." />}>
<MemoizedChild />
</Suspense>
</ValueContext.Provider>
);
}
const root = ReactTestRenderer.create(<App />, {
unstable_isConcurrent: true,
});
expect(Scheduler).toFlushAndYield(['Suspend! [default]', 'Loading...']);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [default]']);
expect(Scheduler).toFlushAndYield(['default']);
expect(root).toMatchRenderedOutput('default');
act(() => setValue('new value'));
expect(Scheduler).toHaveYielded(['Suspend! [new value]', 'Loading...']);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [new value]']);
expect(Scheduler).toFlushAndYield(['new value']);
expect(root).toMatchRenderedOutput('new value');
});
it('updates memoized child of suspense component when context updates (manual memo)', () => {
const {useContext, createContext, useState, memo} = React;
const ValueContext = createContext(null);
const MemoizedChild = memo(
function MemoizedChild() {
const text = useContext(ValueContext);
try {
TextResource.read([text, 1000]);
Scheduler.unstable_yieldValue(text);
return text;
} catch (promise) {
if (typeof promise.then === 'function') {
Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
} else {
Scheduler.unstable_yieldValue(`Error! [${text}]`);
}
throw promise;
}
},
function areEqual(prevProps, nextProps) {
return true;
},
);
let setValue;
function App() {
const [value, _setValue] = useState('default');
setValue = _setValue;
return (
<ValueContext.Provider value={value}>
<Suspense fallback={<Text text="Loading..." />}>
<MemoizedChild />
</Suspense>
</ValueContext.Provider>
);
}
const root = ReactTestRenderer.create(<App />, {
unstable_isConcurrent: true,
});
expect(Scheduler).toFlushAndYield(['Suspend! [default]', 'Loading...']);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [default]']);
expect(Scheduler).toFlushAndYield(['default']);
expect(root).toMatchRenderedOutput('default');
act(() => setValue('new value'));
expect(Scheduler).toHaveYielded(['Suspend! [new value]', 'Loading...']);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [new value]']);
expect(Scheduler).toFlushAndYield(['new value']);
expect(root).toMatchRenderedOutput('new value');
});
it('updates memoized child of suspense component when context updates (function)', () => {
const {useContext, createContext, useState} = React;
const ValueContext = createContext(null);
function MemoizedChild() {
const text = useContext(ValueContext);
try {
TextResource.read([text, 1000]);
Scheduler.unstable_yieldValue(text);
return text;
} catch (promise) {
if (typeof promise.then === 'function') {
Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
} else {
Scheduler.unstable_yieldValue(`Error! [${text}]`);
}
throw promise;
}
}
let setValue;
function App({children}) {
const [value, _setValue] = useState('default');
setValue = _setValue;
return (
<ValueContext.Provider value={value}>{children}</ValueContext.Provider>
);
}
const root = ReactTestRenderer.create(
<App>
<Suspense fallback={<Text text="Loading..." />}>
<MemoizedChild />
</Suspense>
</App>,
{
unstable_isConcurrent: true,
},
);
expect(Scheduler).toFlushAndYield(['Suspend! [default]', 'Loading...']);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [default]']);
expect(Scheduler).toFlushAndYield(['default']);
expect(root).toMatchRenderedOutput('default');
act(() => setValue('new value'));
expect(Scheduler).toHaveYielded(['Suspend! [new value]', 'Loading...']);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [new value]']);
expect(Scheduler).toFlushAndYield(['new value']);
expect(root).toMatchRenderedOutput('new value');
});
it('updates memoized child of suspense component when context updates (forwardRef)', () => {
const {forwardRef, useContext, createContext, useState} = React;
const ValueContext = createContext(null);
const MemoizedChild = forwardRef(() => {
const text = useContext(ValueContext);
try {
TextResource.read([text, 1000]);
Scheduler.unstable_yieldValue(text);
return text;
} catch (promise) {
if (typeof promise.then === 'function') {
Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
} else {
Scheduler.unstable_yieldValue(`Error! [${text}]`);
}
throw promise;
}
});
let setValue;
function App({children}) {
const [value, _setValue] = useState('default');
setValue = _setValue;
return (
<ValueContext.Provider value={value}>{children}</ValueContext.Provider>
);
}
const root = ReactTestRenderer.create(
<App>
<Suspense fallback={<Text text="Loading..." />}>
<MemoizedChild />
</Suspense>
</App>,
{
unstable_isConcurrent: true,
},
);
expect(Scheduler).toFlushAndYield(['Suspend! [default]', 'Loading...']);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [default]']);
expect(Scheduler).toFlushAndYield(['default']);
expect(root).toMatchRenderedOutput('default');
act(() => setValue('new value'));
expect(Scheduler).toHaveYielded(['Suspend! [new value]', 'Loading...']);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [new value]']);
expect(Scheduler).toFlushAndYield(['new value']);
expect(root).toMatchRenderedOutput('new value');
});
it('re-fires layout effects when re-showing Suspense', () => {
function TextWithLayout(props) {
Scheduler.unstable_yieldValue(props.text);
React.useLayoutEffect(() => {
Scheduler.unstable_yieldValue('create layout');
return () => {
Scheduler.unstable_yieldValue('destroy layout');
};
}, []);
return props.text;
}
let _setShow;
function App(props) {
const [show, setShow] = React.useState(false);
_setShow = setShow;
return (
<Suspense fallback={<Text text="Loading..." />}>
<TextWithLayout text="Child 1" />
{show && <AsyncText ms={1000} text="Child 2" />}
</Suspense>
);
}
const root = ReactTestRenderer.create(<App />, {
unstable_isConcurrent: true,
});
expect(Scheduler).toFlushAndYield(['Child 1', 'create layout']);
expect(root).toMatchRenderedOutput('Child 1');
act(() => {
_setShow(true);
});
expect(Scheduler).toHaveYielded([
'Child 1',
'Suspend! [Child 2]',
'Loading...',
]);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded([
'destroy layout',
'Promise resolved [Child 2]',
]);
expect(Scheduler).toFlushAndYield(['Child 1', 'Child 2', 'create layout']);
expect(root).toMatchRenderedOutput(['Child 1', 'Child 2'].join(''));
});
describe('outside concurrent mode', () => {
it('a mounted class component can suspend without losing state', () => {
class TextWithLifecycle extends React.Component {
componentDidMount() {
Scheduler.unstable_yieldValue(`Mount [${this.props.text}]`);
}
componentDidUpdate() {
Scheduler.unstable_yieldValue(`Update [${this.props.text}]`);
}
componentWillUnmount() {
Scheduler.unstable_yieldValue(`Unmount [${this.props.text}]`);
}
render() {
return <Text {...this.props} />;
}
}
let instance;
class AsyncTextWithLifecycle extends React.Component {
state = {step: 1};
componentDidMount() {
Scheduler.unstable_yieldValue(
`Mount [${this.props.text}:${this.state.step}]`,
);
}
componentDidUpdate() {
Scheduler.unstable_yieldValue(
`Update [${this.props.text}:${this.state.step}]`,
);
}
componentWillUnmount() {
Scheduler.unstable_yieldValue(
`Unmount [${this.props.text}:${this.state.step}]`,
);
}
render() {
instance = this;
const text = `${this.props.text}:${this.state.step}`;
const ms = this.props.ms;
try {
TextResource.read([text, ms]);
Scheduler.unstable_yieldValue(text);
return text;
} catch (promise) {
if (typeof promise.then === 'function') {
Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
} else {
Scheduler.unstable_yieldValue(`Error! [${text}]`);
}
throw promise;
}
}
}
function App() {
return (
<Suspense fallback={<TextWithLifecycle text="Loading..." />}>
<TextWithLifecycle text="A" />
<AsyncTextWithLifecycle ms={100} text="B" ref={instance} />
<TextWithLifecycle text="C" />
</Suspense>
);
}
const root = ReactTestRenderer.create(<App />);
expect(Scheduler).toHaveYielded([
'A',
'Suspend! [B:1]',
'C',
'Loading...',
'Mount [A]',
'Mount [C]',
'Mount [Loading...]',
]);
expect(root).toMatchRenderedOutput('Loading...');
jest.advanceTimersByTime(100);
expect(Scheduler).toHaveYielded(['Promise resolved [B:1]']);
expect(Scheduler).toFlushUntilNextPaint([
'B:1',
'Unmount [Loading...]',
'Mount [B:1]',
]);
expect(root).toMatchRenderedOutput('AB:1C');
instance.setState({step: 2});
expect(Scheduler).toHaveYielded([
'Suspend! [B:2]',
'Loading...',
'Mount [Loading...]',
]);
expect(root).toMatchRenderedOutput('Loading...');
jest.advanceTimersByTime(100);
expect(Scheduler).toHaveYielded(['Promise resolved [B:2]']);
expect(Scheduler).toFlushUntilNextPaint([
'B:2',
'Unmount [Loading...]',
'Update [B:2]',
]);
expect(root).toMatchRenderedOutput('AB:2C');
});
it('bails out on timed-out primary children even if they receive an update', () => {
let instance;
class Stateful extends React.Component {
state = {step: 1};
render() {
instance = this;
return <Text text={`Stateful: ${this.state.step}`} />;
}
}
function App(props) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<Stateful />
<AsyncText ms={1000} text={props.text} />
</Suspense>
);
}
const root = ReactTestRenderer.create(<App text="A" />);
expect(Scheduler).toHaveYielded([
'Stateful: 1',
'Suspend! [A]',
'Loading...',
]);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
expect(Scheduler).toFlushUntilNextPaint(['A']);
expect(root).toMatchRenderedOutput('Stateful: 1A');
root.update(<App text="B" />);
expect(Scheduler).toHaveYielded([
'Stateful: 1',
'Suspend! [B]',
'Loading...',
]);
expect(root).toMatchRenderedOutput('Loading...');
instance.setState({step: 2});
expect(Scheduler).toHaveYielded(['Stateful: 2', 'Suspend! [B]']);
expect(root).toMatchRenderedOutput('Loading...');
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
expect(Scheduler).toFlushUntilNextPaint(['B']);
expect(root).toMatchRenderedOutput('Stateful: 2B');
});
it('when updating a timed-out tree, always retries the suspended component', () => {
let instance;
class Stateful extends React.Component {
state = {step: 1};
render() {
instance = this;
return <Text text={`Stateful: ${this.state.step}`} />;
}
}
const Indirection = React.Fragment;
function App(props) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<Stateful />
<Indirection>
<Indirection>
<Indirection>
<AsyncText ms={1000} text={props.text} />
</Indirection>
</Indirection>
</Indirection>
</Suspense>
);
}
const root = ReactTestRenderer.create(<App text="A" />);
expect(Scheduler).toHaveYielded([
'Stateful: 1',
'Suspend! [A]',
'Loading...',
]);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
expect(Scheduler).toFlushUntilNextPaint(['A']);
expect(root).toMatchRenderedOutput('Stateful: 1A');
root.update(<App text="B" />);
expect(Scheduler).toHaveYielded([
'Stateful: 1',
'Suspend! [B]',
'Loading...',
]);
expect(root).toMatchRenderedOutput('Loading...');
instance.setState({step: 2});
expect(Scheduler).toHaveYielded([
'Stateful: 2',
'Suspend! [B]',
]);
expect(root).toMatchRenderedOutput('Loading...');
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
expect(Scheduler).toFlushUntilNextPaint(['B']);
expect(root).toMatchRenderedOutput('Stateful: 2B');
});
it('suspends in a class that has componentWillUnmount and is then deleted', () => {
class AsyncTextWithUnmount extends React.Component {
componentWillUnmount() {
Scheduler.unstable_yieldValue('will unmount');
}
render() {
const text = this.props.text;
const ms = this.props.ms;
try {
TextResource.read([text, ms]);
Scheduler.unstable_yieldValue(text);
return text;
} catch (promise) {
if (typeof promise.then === 'function') {
Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
} else {
Scheduler.unstable_yieldValue(`Error! [${text}]`);
}
throw promise;
}
}
}
function App({text}) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncTextWithUnmount text={text} ms={100} />
</Suspense>
);
}
const root = ReactTestRenderer.create(<App text="A" />);
expect(Scheduler).toHaveYielded(['Suspend! [A]', 'Loading...']);
root.update(<Text text="B" />);
expect(Scheduler).toHaveYielded(['B']);
expect(root).toMatchRenderedOutput('B');
});
it('suspends in a component that also contains useEffect', () => {
const {useLayoutEffect} = React;
function AsyncTextWithEffect(props) {
const text = props.text;
useLayoutEffect(() => {
Scheduler.unstable_yieldValue('Did commit: ' + text);
}, [text]);
try {
TextResource.read([props.text, props.ms]);
Scheduler.unstable_yieldValue(text);
return text;
} catch (promise) {
if (typeof promise.then === 'function') {
Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
} else {
Scheduler.unstable_yieldValue(`Error! [${text}]`);
}
throw promise;
}
}
function App({text}) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncTextWithEffect text={text} ms={100} />
</Suspense>
);
}
ReactTestRenderer.create(<App text="A" />);
expect(Scheduler).toHaveYielded(['Suspend! [A]', 'Loading...']);
jest.advanceTimersByTime(500);
expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
expect(Scheduler).toFlushUntilNextPaint(['A', 'Did commit: A']);
});
it('retries when an update is scheduled on a timed out tree', () => {
let instance;
class Stateful extends React.Component {
state = {step: 1};
render() {
instance = this;
return <AsyncText ms={1000} text={`Step: ${this.state.step}`} />;
}
}
function App(props) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<Stateful />
</Suspense>
);
}
const root = ReactTestRenderer.create(<App />, {
unstable_isConcurrent: true,
});
expect(Scheduler).toFlushAndYield(['Suspend! [Step: 1]', 'Loading...']);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [Step: 1]']);
expect(Scheduler).toFlushAndYield(['Step: 1']);
expect(root).toMatchRenderedOutput('Step: 1');
instance.setState({step: 2});
expect(Scheduler).toFlushAndYield(['Suspend! [Step: 2]', 'Loading...']);
jest.advanceTimersByTime(500);
expect(root).toMatchRenderedOutput('Loading...');
instance.setState({step: 3});
expect(Scheduler).toFlushAndYield(['Suspend! [Step: 3]']);
expect(root).toMatchRenderedOutput('Loading...');
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded([
'Promise resolved [Step: 2]',
'Promise resolved [Step: 3]',
]);
expect(Scheduler).toFlushAndYield(['Step: 3']);
expect(root).toMatchRenderedOutput('Step: 3');
});
it('does not remount the fallback while suspended children resolve in legacy mode', () => {
let mounts = 0;
class ShouldMountOnce extends React.Component {
componentDidMount() {
mounts++;
}
render() {
return <Text text="Loading..." />;
}
}
function App(props) {
return (
<Suspense fallback={<ShouldMountOnce />}>
<AsyncText ms={1000} text="Child 1" />
<AsyncText ms={2000} text="Child 2" />
<AsyncText ms={3000} text="Child 3" />
</Suspense>
);
}
const root = ReactTestRenderer.create(<App />);
expect(Scheduler).toHaveYielded([
'Suspend! [Child 1]',
'Suspend! [Child 2]',
'Suspend! [Child 3]',
'Loading...',
]);
expect(Scheduler).toFlushAndYield([]);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [Child 1]']);
expect(Scheduler).toFlushUntilNextPaint([
'Child 1',
'Suspend! [Child 2]',
'Suspend! [Child 3]',
]);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [Child 2]']);
expect(Scheduler).toFlushUntilNextPaint([
'Child 2',
'Suspend! [Child 3]',
]);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [Child 3]']);
expect(Scheduler).toFlushUntilNextPaint(['Child 3']);
expect(root).toMatchRenderedOutput(
['Child 1', 'Child 2', 'Child 3'].join(''),
);
expect(mounts).toBe(1);
});
it('does not get stuck with fallback in concurrent mode for a large delay', () => {
function App(props) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText ms={1000} text="Child 1" />
<AsyncText ms={7000} text="Child 2" />
</Suspense>
);
}
const root = ReactTestRenderer.create(<App />, {
unstable_isConcurrent: true,
});
expect(Scheduler).toFlushAndYield([
'Suspend! [Child 1]',
'Suspend! [Child 2]',
'Loading...',
]);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [Child 1]']);
expect(Scheduler).toFlushAndYield(['Child 1', 'Suspend! [Child 2]']);
jest.advanceTimersByTime(6000);
expect(Scheduler).toHaveYielded(['Promise resolved [Child 2]']);
expect(Scheduler).toFlushAndYield(['Child 1', 'Child 2']);
expect(root).toMatchRenderedOutput(['Child 1', 'Child 2'].join(''));
});
it('reuses effects, including deletions, from the suspended tree', () => {
const {useState} = React;
let setTab;
function App() {
const [tab, _setTab] = useState(0);
setTab = _setTab;
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText key={tab} text={'Tab: ' + tab} ms={1000} />
<Text key={tab + 'sibling'} text=" + sibling" />
</Suspense>
);
}
const root = ReactTestRenderer.create(<App />);
expect(Scheduler).toHaveYielded([
'Suspend! [Tab: 0]',
' + sibling',
'Loading...',
]);
expect(root).toMatchRenderedOutput('Loading...');
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [Tab: 0]']);
expect(Scheduler).toFlushUntilNextPaint(['Tab: 0']);
expect(root).toMatchRenderedOutput('Tab: 0 + sibling');
act(() => setTab(1));
expect(Scheduler).toHaveYielded([
'Suspend! [Tab: 1]',
' + sibling',
'Loading...',
]);
expect(root).toMatchRenderedOutput('Loading...');
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [Tab: 1]']);
expect(Scheduler).toFlushUntilNextPaint(['Tab: 1']);
expect(root).toMatchRenderedOutput('Tab: 1 + sibling');
act(() => setTab(2));
expect(Scheduler).toHaveYielded([
'Suspend! [Tab: 2]',
' + sibling',
'Loading...',
]);
expect(root).toMatchRenderedOutput('Loading...');
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [Tab: 2]']);
expect(Scheduler).toFlushUntilNextPaint(['Tab: 2']);
expect(root).toMatchRenderedOutput('Tab: 2 + sibling');
});
it('does not warn if an mounted component is pinged', () => {
const {useState} = React;
const root = ReactTestRenderer.create(null);
let setStep;
function UpdatingText({text, ms}) {
const [step, _setStep] = useState(0);
setStep = _setStep;
const fullText = `${text}:${step}`;
try {
TextResource.read([fullText, ms]);
Scheduler.unstable_yieldValue(fullText);
return fullText;
} catch (promise) {
if (typeof promise.then === 'function') {
Scheduler.unstable_yieldValue(`Suspend! [${fullText}]`);
} else {
Scheduler.unstable_yieldValue(`Error! [${fullText}]`);
}
throw promise;
}
}
root.update(
<Suspense fallback={<Text text="Loading..." />}>
<UpdatingText text="A" ms={1000} />
</Suspense>,
);
expect(Scheduler).toHaveYielded(['Suspend! [A:0]', 'Loading...']);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [A:0]']);
expect(Scheduler).toFlushUntilNextPaint(['A:0']);
expect(root).toMatchRenderedOutput('A:0');
act(() => setStep(1));
expect(Scheduler).toHaveYielded(['Suspend! [A:1]', 'Loading...']);
expect(root).toMatchRenderedOutput('Loading...');
root.update(null);
expect(Scheduler).toFlushWithoutYielding();
jest.advanceTimersByTime(1000);
});
it('memoizes promise listeners per thread ID to prevent redundant renders', () => {
function App() {
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="A" ms={1000} />
<AsyncText text="B" ms={2000} />
<AsyncText text="C" ms={3000} />
</Suspense>
);
}
const root = ReactTestRenderer.create(null);
root.update(<App />);
expect(Scheduler).toHaveYielded([
'Suspend! [A]',
'Suspend! [B]',
'Suspend! [C]',
'Loading...',
]);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
expect(Scheduler).toFlushUntilNextPaint([
'A',
'Suspend! [B]',
'Suspend! [C]',
]);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
expect(Scheduler).toFlushUntilNextPaint([
'B',
'Suspend! [C]',
]);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [C]']);
expect(Scheduler).toFlushUntilNextPaint([
'C',
]);
});
it('#14162', () => {
const {lazy} = React;
function Hello() {
return <span>hello</span>;
}
async function fetchComponent() {
return new Promise(r => {
setTimeout(r, 1000, {default: Hello});
});
}
const LazyHello = lazy(fetchComponent);
class App extends React.Component {
state = {render: false};
componentDidMount() {
setTimeout(() => this.setState({render: true}));
}
render() {
return (
<Suspense fallback={<span>loading...</span>}>
{this.state.render && <LazyHello />}
</Suspense>
);
}
}
const root = ReactTestRenderer.create(null);
root.update(<App name="world" />);
jest.advanceTimersByTime(1000);
});
it('updates memoized child of suspense component when context updates (simple memo)', () => {
const {useContext, createContext, useState, memo} = React;
const ValueContext = createContext(null);
const MemoizedChild = memo(function MemoizedChild() {
const text = useContext(ValueContext);
try {
TextResource.read([text, 1000]);
Scheduler.unstable_yieldValue(text);
return text;
} catch (promise) {
if (typeof promise.then === 'function') {
Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
} else {
Scheduler.unstable_yieldValue(`Error! [${text}]`);
}
throw promise;
}
});
let setValue;
function App() {
const [value, _setValue] = useState('default');
setValue = _setValue;
return (
<ValueContext.Provider value={value}>
<Suspense fallback={<Text text="Loading..." />}>
<MemoizedChild />
</Suspense>
</ValueContext.Provider>
);
}
const root = ReactTestRenderer.create(<App />);
expect(Scheduler).toHaveYielded(['Suspend! [default]', 'Loading...']);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [default]']);
expect(Scheduler).toFlushUntilNextPaint(['default']);
expect(root).toMatchRenderedOutput('default');
act(() => setValue('new value'));
expect(Scheduler).toHaveYielded(['Suspend! [new value]', 'Loading...']);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [new value]']);
expect(Scheduler).toFlushUntilNextPaint(['new value']);
expect(root).toMatchRenderedOutput('new value');
});
it('updates memoized child of suspense component when context updates (manual memo)', () => {
const {useContext, createContext, useState, memo} = React;
const ValueContext = createContext(null);
const MemoizedChild = memo(
function MemoizedChild() {
const text = useContext(ValueContext);
try {
TextResource.read([text, 1000]);
Scheduler.unstable_yieldValue(text);
return text;
} catch (promise) {
if (typeof promise.then === 'function') {
Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
} else {
Scheduler.unstable_yieldValue(`Error! [${text}]`);
}
throw promise;
}
},
function areEqual(prevProps, nextProps) {
return true;
},
);
let setValue;
function App() {
const [value, _setValue] = useState('default');
setValue = _setValue;
return (
<ValueContext.Provider value={value}>
<Suspense fallback={<Text text="Loading..." />}>
<MemoizedChild />
</Suspense>
</ValueContext.Provider>
);
}
const root = ReactTestRenderer.create(<App />);
expect(Scheduler).toHaveYielded(['Suspend! [default]', 'Loading...']);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [default]']);
expect(Scheduler).toFlushUntilNextPaint(['default']);
expect(root).toMatchRenderedOutput('default');
act(() => setValue('new value'));
expect(Scheduler).toHaveYielded(['Suspend! [new value]', 'Loading...']);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [new value]']);
expect(Scheduler).toFlushUntilNextPaint(['new value']);
expect(root).toMatchRenderedOutput('new value');
});
it('updates memoized child of suspense component when context updates (function)', () => {
const {useContext, createContext, useState} = React;
const ValueContext = createContext(null);
function MemoizedChild() {
const text = useContext(ValueContext);
try {
TextResource.read([text, 1000]);
Scheduler.unstable_yieldValue(text);
return text;
} catch (promise) {
if (typeof promise.then === 'function') {
Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
} else {
Scheduler.unstable_yieldValue(`Error! [${text}]`);
}
throw promise;
}
}
let setValue;
function App({children}) {
const [value, _setValue] = useState('default');
setValue = _setValue;
return (
<ValueContext.Provider value={value}>
{children}
</ValueContext.Provider>
);
}
const root = ReactTestRenderer.create(
<App>
<Suspense fallback={<Text text="Loading..." />}>
<MemoizedChild />
</Suspense>
</App>,
);
expect(Scheduler).toHaveYielded(['Suspend! [default]', 'Loading...']);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [default]']);
expect(Scheduler).toFlushUntilNextPaint(['default']);
expect(root).toMatchRenderedOutput('default');
act(() => setValue('new value'));
expect(Scheduler).toHaveYielded(['Suspend! [new value]', 'Loading...']);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [new value]']);
expect(Scheduler).toFlushUntilNextPaint(['new value']);
expect(root).toMatchRenderedOutput('new value');
});
it('updates memoized child of suspense component when context updates (forwardRef)', () => {
const {forwardRef, useContext, createContext, useState} = React;
const ValueContext = createContext(null);
const MemoizedChild = forwardRef(function MemoizedChild() {
const text = useContext(ValueContext);
try {
TextResource.read([text, 1000]);
Scheduler.unstable_yieldValue(text);
return text;
} catch (promise) {
if (typeof promise.then === 'function') {
Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
} else {
Scheduler.unstable_yieldValue(`Error! [${text}]`);
}
throw promise;
}
});
let setValue;
function App() {
const [value, _setValue] = useState('default');
setValue = _setValue;
return (
<ValueContext.Provider value={value}>
<Suspense fallback={<Text text="Loading..." />}>
<MemoizedChild />
</Suspense>
</ValueContext.Provider>
);
}
const root = ReactTestRenderer.create(<App />);
expect(Scheduler).toHaveYielded(['Suspend! [default]', 'Loading...']);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [default]']);
expect(Scheduler).toFlushUntilNextPaint(['default']);
expect(root).toMatchRenderedOutput('default');
act(() => setValue('new value'));
expect(Scheduler).toHaveYielded(['Suspend! [new value]', 'Loading...']);
jest.advanceTimersByTime(1000);
expect(Scheduler).toHaveYielded(['Promise resolved [new value]']);
expect(Scheduler).toFlushUntilNextPaint(['new value']);
expect(root).toMatchRenderedOutput('new value');
});
it('updates context consumer within child of suspended suspense component when context updates', () => {
const {createContext, useState} = React;
const ValueContext = createContext(null);
const promiseThatNeverResolves = new Promise(() => {});
function Child() {
return (
<ValueContext.Consumer>
{value => {
Scheduler.unstable_yieldValue(
`Received context value [${value}]`,
);
if (value === 'default') return <Text text="default" />;
throw promiseThatNeverResolves;
}}
</ValueContext.Consumer>
);
}
let setValue;
function Wrapper({children}) {
const [value, _setValue] = useState('default');
setValue = _setValue;
return (
<ValueContext.Provider value={value}>
{children}
</ValueContext.Provider>
);
}
function App() {
return (
<Wrapper>
<Suspense fallback={<Text text="Loading..." />}>
<Child />
</Suspense>
</Wrapper>
);
}
const root = ReactTestRenderer.create(<App />);
expect(Scheduler).toHaveYielded([
'Received context value [default]',
'default',
]);
expect(root).toMatchRenderedOutput('default');
act(() => setValue('new value'));
expect(Scheduler).toHaveYielded([
'Received context value [new value]',
'Loading...',
]);
expect(root).toMatchRenderedOutput('Loading...');
act(() => setValue('default'));
expect(Scheduler).toHaveYielded([
'Received context value [default]',
'default',
]);
expect(root).toMatchRenderedOutput('default');
});
});
});