let React;
let Fragment;
let ReactNoop;
let Scheduler;
let act;
let waitFor;
let waitForAll;
let waitForMicrotasks;
let assertLog;
let waitForPaint;
let Suspense;
let startTransition;
let getCacheForType;
let caches;
let seededCache;
describe('ReactSuspenseWithNoopRenderer', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
Fragment = React.Fragment;
ReactNoop = require('react-noop-renderer');
Scheduler = require('scheduler');
act = require('internal-test-utils').act;
Suspense = React.Suspense;
startTransition = React.startTransition;
const InternalTestUtils = require('internal-test-utils');
waitFor = InternalTestUtils.waitFor;
waitForAll = InternalTestUtils.waitForAll;
waitForPaint = InternalTestUtils.waitForPaint;
waitForMicrotasks = InternalTestUtils.waitForMicrotasks;
assertLog = InternalTestUtils.assertLog;
getCacheForType = React.unstable_getCacheForType;
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 <span prop={text} />;
}
function AsyncText({text, showVersion}) {
const version = readText(text);
const fullText = showVersion ? `${text} [v${version}]` : text;
Scheduler.log(fullText);
return <span prop={fullText} />;
}
function seedNextTextCache(text) {
if (seededCache === null) {
seededCache = createTextCache();
}
seededCache.resolve(text);
}
function resolveMostRecentTextCache(text) {
if (caches.length === 0) {
throw Error('Cache does not exist.');
} else {
caches[caches.length - 1].resolve(text);
}
}
const resolveText = resolveMostRecentTextCache;
function rejectMostRecentTextCache(text, error) {
if (caches.length === 0) {
throw Error('Cache does not exist.');
} else {
caches[caches.length - 1].reject(text, error);
}
}
const rejectText = rejectMostRecentTextCache;
function advanceTimers(ms) {
if (typeof ms !== 'number') {
throw new Error('Must specify ms');
}
jest.advanceTimersByTime(ms);
return Promise.resolve().then(() => {});
}
function LegacyHiddenDiv({children, mode}) {
return (
<div hidden={mode === 'hidden'}>
<React.unstable_LegacyHidden
mode={mode === 'hidden' ? 'unstable-defer-without-hiding' : mode}>
{children}
</React.unstable_LegacyHidden>
</div>
);
}
it("does not restart if there's a ping during initial render", async () => {
function Bar(props) {
Scheduler.log('Bar');
return props.children;
}
function Foo() {
Scheduler.log('Foo');
return (
<>
<Suspense fallback={<Text text="Loading..." />}>
<Bar>
<AsyncText text="A" />
<Text text="B" />
</Bar>
</Suspense>
<Text text="C" />
<Text text="D" />
</>
);
}
React.startTransition(() => {
ReactNoop.render(<Foo />);
});
await waitFor([
'Foo',
'Bar',
'Suspend! [A]',
'Loading...',
'C',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
await act(async () => {
await resolveText('A');
await waitForPaint(['D']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Loading..." />
<span prop="C" />
<span prop="D" />
</>,
);
await waitForAll(['Bar', 'A', 'B']);
});
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="B" />
<span prop="C" />
<span prop="D" />
</>,
);
});
it('suspends rendering and continues later', async () => {
function Bar(props) {
Scheduler.log('Bar');
return props.children;
}
function Foo({renderBar}) {
Scheduler.log('Foo');
return (
<Suspense fallback={<Text text="Loading..." />}>
{renderBar ? (
<Bar>
<AsyncText text="A" />
<Text text="B" />
</Bar>
) : null}
</Suspense>
);
}
ReactNoop.render(<Foo />);
await waitForAll(['Foo']);
React.startTransition(() => {
ReactNoop.render(<Foo renderBar={true} />);
});
await waitForAll([
'Foo',
'Bar',
'Suspend! [A]',
...(gate('enableSiblingPrerendering') ? ['B'] : []),
'Loading...',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
await resolveText('A');
await waitForAll(['Foo', 'Bar', 'A', 'B']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="B" />
</>,
);
});
it('suspends siblings and later recovers each independently', async () => {
ReactNoop.render(
<Fragment>
<Suspense fallback={<Text text="Loading A..." />}>
<AsyncText text="A" />
</Suspense>
<Suspense fallback={<Text text="Loading B..." />}>
<AsyncText text="B" />
</Suspense>
</Fragment>,
);
await waitForAll([
'Suspend! [A]',
'Loading A...',
'Suspend! [B]',
'Loading B...',
...(gate('enableSiblingPrerendering')
? ['Suspend! [A]', 'Suspend! [B]']
: []),
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Loading A..." />
<span prop="Loading B..." />
</>,
);
await act(() => resolveText('A'));
assertLog([
'A',
...(gate('enableSiblingPrerendering')
? ['Suspend! [B]', 'Suspend! [B]']
: []),
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="Loading B..." />
</>,
);
await act(() => resolveText('B'));
assertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="B" />
</>,
);
});
it('when something suspends, unwinds immediately without rendering siblings', async () => {
ReactNoop.render(<Suspense fallback={<Text text="Loading..." />} />);
await waitForAll([]);
React.startTransition(() => {
ReactNoop.render(
<Suspense fallback={<Text text="Loading..." />}>
<Text text="A" />
<AsyncText text="B" />
<Text text="C" />
<Text text="D" />
</Suspense>,
);
});
await waitForAll([
'A',
'Suspend! [B]',
...(gate('enableSiblingPrerendering') ? ['C', 'D'] : []),
'Loading...',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
await resolveText('B');
await waitForAll(['A', 'B', 'C', 'D']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="B" />
<span prop="C" />
<span prop="D" />
</>,
);
});
it('retries on error', async () => {
class ErrorBoundary extends React.Component {
state = {error: null};
componentDidCatch(error) {
this.setState({error});
}
reset() {
this.setState({error: null});
}
render() {
if (this.state.error !== null) {
return <Text text={'Caught error: ' + this.state.error.message} />;
}
return this.props.children;
}
}
const errorBoundary = React.createRef();
function App({renderContent}) {
return (
<Suspense fallback={<Text text="Loading..." />}>
{renderContent ? (
<ErrorBoundary ref={errorBoundary}>
<AsyncText text="Result" />
</ErrorBoundary>
) : null}
</Suspense>
);
}
ReactNoop.render(<App />);
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput(null);
React.startTransition(() => {
ReactNoop.render(<App renderContent={true} />);
});
await waitForAll(['Suspend! [Result]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(null);
await rejectText('Result', new Error('Failed to load: Result'));
await waitForAll([
'Error! [Result]',
'Error! [Result]',
'Caught error: Failed to load: Result',
]);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Caught error: Failed to load: Result" />,
);
});
it('retries on error after falling back to a placeholder', async () => {
class ErrorBoundary extends React.Component {
state = {error: null};
componentDidCatch(error) {
this.setState({error});
}
reset() {
this.setState({error: null});
}
render() {
if (this.state.error !== null) {
return <Text text={'Caught error: ' + this.state.error.message} />;
}
return this.props.children;
}
}
const errorBoundary = React.createRef();
function App() {
return (
<Suspense fallback={<Text text="Loading..." />}>
<ErrorBoundary ref={errorBoundary}>
<AsyncText text="Result" />
</ErrorBoundary>
</Suspense>
);
}
ReactNoop.render(<App />);
await waitForAll([
'Suspend! [Result]',
'Loading...',
...(gate('enableSiblingPrerendering') ? ['Suspend! [Result]'] : []),
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
await act(() => rejectText('Result', new Error('Failed to load: Result')));
assertLog([
'Error! [Result]',
'Error! [Result]',
'Caught error: Failed to load: Result',
]);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Caught error: Failed to load: Result" />,
);
});
it('can update at a higher priority while in a suspended state', async () => {
let setHighPri;
function HighPri() {
const [text, setText] = React.useState('A');
setHighPri = setText;
return <Text text={text} />;
}
let setLowPri;
function LowPri() {
const [text, setText] = React.useState('1');
setLowPri = setText;
return <AsyncText text={text} />;
}
function App() {
return (
<>
<HighPri />
<Suspense fallback={<Text text="Loading..." />}>
<LowPri />
</Suspense>
</>
);
}
await act(() => ReactNoop.render(<App />));
assertLog([
'A',
'Suspend! [1]',
'Loading...',
...(gate('enableSiblingPrerendering') ? ['Suspend! [1]'] : []),
]);
await act(() => resolveText('1'));
assertLog(['1']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="1" />
</>,
);
await act(() => startTransition(() => setLowPri('2')));
assertLog(['Suspend! [2]', 'Loading...']);
ReactNoop.flushSync(() => {
setHighPri('B');
});
assertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="B" />
<span prop="1" />
</>,
);
await act(() => resolveText('2'));
assertLog(['2']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="B" />
<span prop="2" />
</>,
);
});
it('keeps working on lower priority work after being pinged', async () => {
function App(props) {
return (
<Suspense fallback={<Text text="Loading..." />}>
{props.showA && <AsyncText text="A" />}
{props.showB && <Text text="B" />}
</Suspense>
);
}
ReactNoop.render(<App showA={false} showB={false} />);
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput(null);
React.startTransition(() => {
ReactNoop.render(<App showA={true} showB={false} />);
});
await waitForAll(['Suspend! [A]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(null);
React.startTransition(() => {
ReactNoop.render(<App showA={true} showB={true} />);
});
await waitForAll([
'Suspend! [A]',
...(gate('enableSiblingPrerendering') ? ['B'] : []),
'Loading...',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
await resolveText('A');
await waitForAll(['A', 'B']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="B" />
</>,
);
});
it('tries rendering a lower priority pending update even if a higher priority one suspends', async () => {
function App(props) {
if (props.hide) {
return <Text text="(empty)" />;
}
return (
<Suspense fallback="Loading...">
<AsyncText text="Async" />
</Suspense>
);
}
ReactNoop.render(<App />);
React.startTransition(() => {
ReactNoop.render(<App hide={true} />);
});
await waitForAll([
'Suspend! [Async]',
'(empty)',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="(empty)" />);
});
it('tries each subsequent level after suspending', async () => {
const root = ReactNoop.createRoot();
function App({step, shouldSuspend}) {
return (
<Suspense fallback="Loading...">
<Text text="Sibling" />
{shouldSuspend ? (
<AsyncText text={'Step ' + step} />
) : (
<Text text={'Step ' + step} />
)}
</Suspense>
);
}
function interrupt() {
ReactNoop.flushSync(() => {
ReactNoop.renderToRootWithID(null, 'other-root');
});
}
await act(() => {
root.render(<App step={0} shouldSuspend={false} />);
});
await advanceTimers(1000);
assertLog(['Sibling', 'Step 0']);
await act(async () => {
React.startTransition(() => {
root.render(<App step={1} shouldSuspend={true} />);
});
Scheduler.unstable_advanceTime(1000);
await waitFor(['Sibling']);
interrupt();
React.startTransition(() => {
root.render(<App step={2} shouldSuspend={true} />);
});
Scheduler.unstable_advanceTime(1000);
await waitFor(['Sibling']);
interrupt();
React.startTransition(() => {
root.render(<App step={3} shouldSuspend={true} />);
});
Scheduler.unstable_advanceTime(1000);
await waitFor(['Sibling']);
interrupt();
root.render(<App step={4} shouldSuspend={false} />);
});
assertLog(['Sibling', 'Step 4']);
});
it('switches to an inner fallback after suspending for a while', async () => {
ReactNoop.expire(200);
ReactNoop.render(
<Fragment>
<Text text="Sync" />
<Suspense fallback={<Text text="Loading outer..." />}>
<AsyncText text="Outer content" />
<Suspense fallback={<Text text="Loading inner..." />}>
<AsyncText text="Inner content" />
</Suspense>
</Suspense>
</Fragment>,
);
await waitForAll([
'Sync',
'Suspend! [Outer content]',
'Loading outer...',
...(gate('enableSiblingPrerendering')
? [
'Suspend! [Outer content]',
'Suspend! [Inner content]',
'Loading inner...',
]
: []),
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Sync" />
<span prop="Loading outer..." />
</>,
);
await resolveText('Outer content');
await waitForAll([
'Outer content',
'Suspend! [Inner content]',
'Loading inner...',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Sync" />
<span prop="Loading outer..." />
</>,
);
ReactNoop.expire(500);
await advanceTimers(500);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Sync" />
<span prop="Outer content" />
<span prop="Loading inner..." />
</>,
);
await act(() => resolveText('Inner content'));
assertLog(['Inner content']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Sync" />
<span prop="Outer content" />
<span prop="Inner content" />
</>,
);
});
it('renders an Suspense boundary synchronously', async () => {
spyOnDev(console, 'error');
ReactNoop.flushSync(() =>
ReactNoop.render(
<Fragment>
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Async" />
</Suspense>
<Text text="Sync" />
</Fragment>,
),
);
assertLog([
'Suspend! [Async]',
'Loading...',
'Sync',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Loading..." />
<span prop="Sync" />
</>,
);
await act(() => resolveText('Async'));
assertLog(['Async']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Async" />
<span prop="Sync" />
</>,
);
});
it('suspending inside an expired expiration boundary will bubble to the next one', async () => {
ReactNoop.flushSync(() =>
ReactNoop.render(
<Fragment>
<Suspense fallback={<Text text="Loading (outer)..." />}>
<Suspense fallback={<AsyncText text="Loading (inner)..." />}>
<AsyncText text="Async" />
</Suspense>
<Text text="Sync" />
</Suspense>
</Fragment>,
),
);
assertLog([
'Suspend! [Async]',
'Suspend! [Loading (inner)...]',
'Loading (outer)...',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading (outer)..." />);
});
it('resolves successfully even if fallback render is pending', async () => {
const root = ReactNoop.createRoot();
root.render(
<>
<Suspense fallback={<Text text="Loading..." />} />
</>,
);
await waitForAll([]);
expect(root).toMatchRenderedOutput(null);
React.startTransition(() => {
root.render(
<>
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Async" />
<Text text="Sibling" />
</Suspense>
</>,
);
});
await waitFor(['Suspend! [Async]']);
await resolveText('Async');
await waitForAll(['Async', 'Sibling']);
expect(root).toMatchRenderedOutput(
<>
<span prop="Async" />
<span prop="Sibling" />
</>,
);
});
it('in concurrent mode, does not error when an update suspends without a Suspense boundary during a sync update', () => {
expect(() => {
ReactNoop.flushSync(() => {
ReactNoop.render(<AsyncText text="Async" />);
});
}).not.toThrow();
});
it('in legacy mode, errors when an update suspends without a Suspense boundary during a sync update', async () => {
const root = ReactNoop.createLegacyRoot();
await expect(async () => {
await act(() => root.render(<AsyncText text="Async" />));
}).rejects.toThrow(
'A component suspended while responding to synchronous input.',
);
});
it('a Suspense component correctly handles more than one suspended child', async () => {
ReactNoop.render(
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="A" />
<AsyncText text="B" />
</Suspense>,
);
await waitForAll([
'Suspend! [A]',
'Loading...',
...(gate('enableSiblingPrerendering')
? ['Suspend! [A]', 'Suspend! [B]']
: []),
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
await act(() => {
resolveText('A');
resolveText('B');
});
assertLog(['A', 'B']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="B" />
</>,
);
});
it('can resume rendering earlier than a timeout', async () => {
ReactNoop.render(<Suspense fallback={<Text text="Loading..." />} />);
await waitForAll([]);
React.startTransition(() => {
ReactNoop.render(
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Async" />
</Suspense>,
);
});
await waitForAll(['Suspend! [Async]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(null);
await resolveText('Async');
await waitForAll(['Async']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Async" />);
});
it('starts working on an update even if its priority falls between two suspended levels', async () => {
function App(props) {
return (
<Suspense fallback={<Text text="Loading..." />}>
{props.text === 'C' || props.text === 'S' ? (
<Text text={props.text} />
) : (
<AsyncText text={props.text} />
)}
</Suspense>
);
}
ReactNoop.render(<App text="S" />);
await waitForAll(['S']);
React.startTransition(() => ReactNoop.render(<App text="A" />));
await waitForAll(['Suspend! [A]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="S" />);
await advanceTimers(4999);
ReactNoop.expire(4999);
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="S" />);
React.startTransition(() => ReactNoop.render(<App text="B" />));
await waitForAll(['Suspend! [B]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="S" />);
ReactNoop.render(<App text="C" />);
await waitForAll(['C']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="C" />);
await resolveText('A');
await resolveText('B');
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="C" />);
});
it('a suspended update that expires', async () => {
function ExpensiveText({text}) {
Scheduler.unstable_advanceTime(10000);
return <AsyncText text={text} />;
}
function App() {
return (
<Suspense fallback="Loading...">
<ExpensiveText text="A" />
<ExpensiveText text="B" />
<ExpensiveText text="C" />
</Suspense>
);
}
ReactNoop.render(<App />);
await waitForAll([
'Suspend! [A]',
...(gate('enableSiblingPrerendering')
? ['Suspend! [A]', 'Suspend! [B]', 'Suspend! [C]']
: []),
]);
expect(ReactNoop).toMatchRenderedOutput('Loading...');
await resolveText('A');
await resolveText('B');
await resolveText('C');
await waitForAll(['A', 'B', 'C']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="B" />
<span prop="C" />
</>,
);
});
describe('legacy mode mode', () => {
it('times out immediately', async () => {
function App() {
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Result" />
</Suspense>
);
}
ReactNoop.renderLegacySyncRoot(<App />);
assertLog(['Suspend! [Result]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
await act(() => {
resolveText('Result');
});
assertLog(['Result']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Result" />);
});
it('times out immediately when Suspense is in legacy mode', async () => {
class UpdatingText extends React.Component {
state = {step: 1};
render() {
return <AsyncText text={`Step: ${this.state.step}`} />;
}
}
function Spinner() {
return (
<Fragment>
<Text text="Loading (1)" />
<Text text="Loading (2)" />
<Text text="Loading (3)" />
</Fragment>
);
}
const text = React.createRef(null);
function App() {
return (
<Suspense fallback={<Spinner />}>
<UpdatingText ref={text} />
<Text text="Sibling" />
</Suspense>
);
}
await seedNextTextCache('Step: 1');
ReactNoop.renderLegacySyncRoot(<App />);
assertLog(['Step: 1', 'Sibling']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Step: 1" />
<span prop="Sibling" />
</>,
);
text.current.setState({step: 2}, () =>
Scheduler.log('Update did commit'),
);
expect(ReactNoop.flushNextYield()).toEqual([
'Suspend! [Step: 2]',
'Loading (1)',
'Loading (2)',
'Loading (3)',
'Update did commit',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span hidden={true} prop="Step: 1" />
<span hidden={true} prop="Sibling" />
<span prop="Loading (1)" />
<span prop="Loading (2)" />
<span prop="Loading (3)" />
</>,
);
await act(() => {
resolveText('Step: 2');
});
assertLog(['Step: 2']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Step: 2" />
<span prop="Sibling" />
</>,
);
});
it('does not re-render siblings in loose mode', async () => {
class TextWithLifecycle extends React.Component {
componentDidMount() {
Scheduler.log(`Mount [${this.props.text}]`);
}
componentDidUpdate() {
Scheduler.log(`Update [${this.props.text}]`);
}
render() {
return <Text {...this.props} />;
}
}
class AsyncTextWithLifecycle extends React.Component {
componentDidMount() {
Scheduler.log(`Mount [${this.props.text}]`);
}
componentDidUpdate() {
Scheduler.log(`Update [${this.props.text}]`);
}
render() {
return <AsyncText {...this.props} />;
}
}
function App() {
return (
<Suspense fallback={<TextWithLifecycle text="Loading..." />}>
<TextWithLifecycle text="A" />
<AsyncTextWithLifecycle text="B" />
<TextWithLifecycle text="C" />
</Suspense>
);
}
ReactNoop.renderLegacySyncRoot(<App />, () =>
Scheduler.log('Commit root'),
);
assertLog([
'A',
'Suspend! [B]',
'C',
'Loading...',
'Mount [A]',
'Mount [B]',
'Mount [C]',
'Mount [Loading...]',
'Commit root',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span hidden={true} prop="A" />
<span hidden={true} prop="C" />
<span prop="Loading..." />
</>,
);
await act(() => {
resolveText('B');
});
assertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="B" />
<span prop="C" />
</>,
);
});
it('suspends inside constructor', async () => {
class AsyncTextInConstructor extends React.Component {
constructor(props) {
super(props);
const text = props.text;
Scheduler.log('constructor');
readText(text);
this.state = {text};
}
componentDidMount() {
Scheduler.log('componentDidMount');
}
render() {
Scheduler.log(this.state.text);
return <span prop={this.state.text} />;
}
}
ReactNoop.renderLegacySyncRoot(
<Suspense fallback={<Text text="Loading..." />}>
<AsyncTextInConstructor text="Hi" />
</Suspense>,
);
assertLog(['constructor', 'Suspend! [Hi]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
await act(() => {
resolveText('Hi');
});
assertLog(['constructor', 'Hi', 'componentDidMount']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Hi" />);
});
it('does not infinite loop if fallback contains lifecycle method', async () => {
class Fallback extends React.Component {
state = {
name: 'foo',
};
componentDidMount() {
this.setState({
name: 'bar',
});
}
render() {
return <Text text="Loading..." />;
}
}
class Demo extends React.Component {
render() {
return (
<Suspense fallback={<Fallback />}>
<AsyncText text="Hi" />
</Suspense>
);
}
}
ReactNoop.renderLegacySyncRoot(<Demo />);
assertLog([
'Suspend! [Hi]',
'Loading...',
'Loading...',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
await act(() => {
resolveText('Hi');
});
assertLog(['Hi']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Hi" />);
});
if (global.__PERSISTENT__) {
it('hides/unhides suspended children before layout effects fire (persistent)', async () => {
const {useRef, useLayoutEffect} = React;
function Parent() {
const child = useRef(null);
useLayoutEffect(() => {
Scheduler.log(ReactNoop.getPendingChildrenAsJSX());
});
return (
<span ref={child} hidden={false}>
<AsyncText text="Hi" />
</span>
);
}
function App(props) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<Parent />
</Suspense>
);
}
ReactNoop.renderLegacySyncRoot(<App middleText="B" />);
assertLog([
'Suspend! [Hi]',
'Loading...',
<>
<span hidden={true} />
<span prop="Loading..." />
</>,
]);
await act(() => {
resolveText('Hi');
});
assertLog(['Hi']);
});
} else {
it('hides/unhides suspended children before layout effects fire (mutation)', async () => {
const {useRef, useLayoutEffect} = React;
function Parent() {
const child = useRef(null);
useLayoutEffect(() => {
Scheduler.log('Child is hidden: ' + child.current.hidden);
});
return (
<span ref={child} hidden={false}>
<AsyncText text="Hi" />
</span>
);
}
function App(props) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<Parent />
</Suspense>
);
}
ReactNoop.renderLegacySyncRoot(<App middleText="B" />);
assertLog([
'Suspend! [Hi]',
'Loading...',
'Child is hidden: true',
]);
await act(() => {
resolveText('Hi');
});
assertLog(['Hi']);
});
}
it('handles errors in the return path of a component that suspends', async () => {
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error, errorInfo) {
return {error};
}
render() {
if (this.state.error) {
return `Caught an error: ${this.state.error.message}`;
}
return this.props.children;
}
}
ReactNoop.renderLegacySyncRoot(
<ErrorBoundary>
<Suspense fallback="Loading...">
<errorInCompletePhase>
<AsyncText text="Async" />
</errorInCompletePhase>
</Suspense>
</ErrorBoundary>,
);
assertLog(['Suspend! [Async]']);
expect(ReactNoop).toMatchRenderedOutput(
'Caught an error: Error in host config.',
);
});
it('does not drop mounted effects', async () => {
const never = {then() {}};
let setShouldSuspend;
function App() {
const [shouldSuspend, _setShouldSuspend] = React.useState(0);
setShouldSuspend = _setShouldSuspend;
return (
<Suspense fallback="Loading...">
<Child shouldSuspend={shouldSuspend} />
</Suspense>
);
}
function Child({shouldSuspend}) {
if (shouldSuspend) {
throw never;
}
React.useEffect(() => {
Scheduler.log('Mount');
return () => {
Scheduler.log('Unmount');
};
}, []);
return 'Child';
}
const root = ReactNoop.createLegacyRoot(null);
await act(() => {
root.render(<App />);
});
assertLog(['Mount']);
expect(root).toMatchRenderedOutput('Child');
await act(() => {
setShouldSuspend(true);
});
expect(root).toMatchRenderedOutput('Loading...');
await act(() => {
root.render(null);
});
assertLog(['Unmount']);
});
});
it('does not call lifecycles of a suspended component', async () => {
class TextWithLifecycle extends React.Component {
componentDidMount() {
Scheduler.log(`Mount [${this.props.text}]`);
}
componentDidUpdate() {
Scheduler.log(`Update [${this.props.text}]`);
}
componentWillUnmount() {
Scheduler.log(`Unmount [${this.props.text}]`);
}
render() {
return <Text {...this.props} />;
}
}
class AsyncTextWithLifecycle extends React.Component {
componentDidMount() {
Scheduler.log(`Mount [${this.props.text}]`);
}
componentDidUpdate() {
Scheduler.log(`Update [${this.props.text}]`);
}
componentWillUnmount() {
Scheduler.log(`Unmount [${this.props.text}]`);
}
render() {
const text = this.props.text;
readText(text);
Scheduler.log(text);
return <span prop={text} />;
}
}
function App() {
return (
<Suspense fallback={<TextWithLifecycle text="Loading..." />}>
<TextWithLifecycle text="A" />
<AsyncTextWithLifecycle text="B" />
<TextWithLifecycle text="C" />
</Suspense>
);
}
ReactNoop.renderLegacySyncRoot(<App />, () => Scheduler.log('Commit root'));
assertLog([
'A',
'Suspend! [B]',
'C',
'Loading...',
'Mount [A]',
'Mount [C]',
'Mount [Loading...]',
'Commit root',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span hidden={true} prop="A" />
<span hidden={true} prop="C" />
<span prop="Loading..." />
</>,
);
});
it('does not call lifecycles of a suspended component (hooks)', async () => {
function TextWithLifecycle(props) {
React.useLayoutEffect(() => {
Scheduler.log(`Layout Effect [${props.text}]`);
return () => {
Scheduler.log(`Destroy Layout Effect [${props.text}]`);
};
}, [props.text]);
React.useEffect(() => {
Scheduler.log(`Effect [${props.text}]`);
return () => {
Scheduler.log(`Destroy Effect [${props.text}]`);
};
}, [props.text]);
return <Text {...props} />;
}
function AsyncTextWithLifecycle(props) {
React.useLayoutEffect(() => {
Scheduler.log(`Layout Effect [${props.text}]`);
return () => {
Scheduler.log(`Destroy Layout Effect [${props.text}]`);
};
}, [props.text]);
React.useEffect(() => {
Scheduler.log(`Effect [${props.text}]`);
return () => {
Scheduler.log(`Destroy Effect [${props.text}]`);
};
}, [props.text]);
const text = props.text;
readText(text);
Scheduler.log(text);
return <span prop={text} />;
}
function App({text}) {
return (
<Suspense fallback={<TextWithLifecycle text="Loading..." />}>
<TextWithLifecycle text="A" />
<AsyncTextWithLifecycle text={text} />
<TextWithLifecycle text="C" />
</Suspense>
);
}
ReactNoop.renderLegacySyncRoot(<App text="B" />, () =>
Scheduler.log('Commit root'),
);
assertLog([
'A',
'Suspend! [B]',
'C',
'Loading...',
'Layout Effect [A]',
'Layout Effect [C]',
'Layout Effect [Loading...]',
'Commit root',
]);
await waitForAll([
'Effect [A]',
'Effect [C]',
'Effect [Loading...]',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span hidden={true} prop="A" />
<span hidden={true} prop="C" />
<span prop="Loading..." />
</>,
);
await act(() => {
resolveText('B');
});
assertLog([
'B',
'Destroy Layout Effect [Loading...]',
'Layout Effect [B]',
'Destroy Effect [Loading...]',
'Effect [B]',
]);
ReactNoop.renderLegacySyncRoot(<App text="B2" />, () =>
Scheduler.log('Commit root'),
);
assertLog([
'A',
'Suspend! [B2]',
'C',
'Loading...',
'Layout Effect [Loading...]',
'Commit root',
]);
await waitForAll([
'Effect [Loading...]',
]);
await act(() => {
resolveText('B2');
});
assertLog([
'B2',
'Destroy Layout Effect [Loading...]',
'Destroy Layout Effect [B]',
'Layout Effect [B2]',
'Destroy Effect [Loading...]',
'Destroy Effect [B]',
'Effect [B2]',
]);
});
it('does not suspends if a fallback has been shown for a long time', async () => {
function Foo() {
Scheduler.log('Foo');
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="A" />
<Suspense fallback={<Text text="Loading more..." />}>
<AsyncText text="B" />
</Suspense>
</Suspense>
);
}
ReactNoop.render(<Foo />);
await waitForAll([
'Foo',
'Suspend! [A]',
'Loading...',
...(gate('enableSiblingPrerendering')
? ['Suspend! [A]', 'Suspend! [B]', 'Loading more...']
: []),
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
Scheduler.unstable_advanceTime(5000);
await advanceTimers(5000);
await resolveText('A');
await waitForAll([
'A',
'Suspend! [B]',
'Loading more...',
...(gate('enableSiblingPrerendering') ? ['Suspend! [B]'] : []),
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="Loading more..." />
</>,
);
await act(() => resolveText('B'));
assertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="B" />
</>,
);
});
it('throttles content from appearing if a fallback was shown recently', async () => {
function Foo() {
Scheduler.log('Foo');
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="A" />
<Suspense fallback={<Text text="Loading more..." />}>
<AsyncText text="B" />
</Suspense>
</Suspense>
);
}
ReactNoop.render(<Foo />);
await waitForAll([
'Foo',
'Suspend! [A]',
'Loading...',
...(gate('enableSiblingPrerendering')
? ['Suspend! [A]', 'Suspend! [B]', 'Loading more...']
: []),
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
await act(async () => {
await resolveText('A');
await waitForAll([
'A',
'Suspend! [B]',
'Loading more...',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
await resolveText('B');
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
await waitForAll(['A', 'B']);
if (gate(flags => flags.alwaysThrottleRetries)) {
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
} else {
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="B" />
</>,
);
}
});
assertLog([]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="B" />
</>,
);
});
it('throttles content from appearing if a fallback was filled in recently', async () => {
function Foo() {
Scheduler.log('Foo');
return (
<>
<Suspense fallback={<Text text="Loading A..." />}>
<AsyncText text="A" />
</Suspense>
<Suspense fallback={<Text text="Loading B..." />}>
<AsyncText text="B" />
</Suspense>
</>
);
}
ReactNoop.render(<Foo />);
await waitForAll([
'Foo',
'Suspend! [A]',
'Loading A...',
'Suspend! [B]',
'Loading B...',
...(gate('enableSiblingPrerendering')
? ['Suspend! [A]', 'Suspend! [B]']
: []),
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Loading A..." />
<span prop="Loading B..." />
</>,
);
await act(async () => {
await resolveText('A');
Scheduler.unstable_advanceTime(10000);
jest.advanceTimersByTime(10000);
await waitForPaint(['A']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="Loading B..." />
</>,
);
});
Scheduler.unstable_advanceTime(200);
jest.advanceTimersByTime(200);
await act(async () => {
await resolveText('B');
await waitForPaint(['B']);
if (gate(flags => flags.alwaysThrottleRetries)) {
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="Loading B..." />
</>,
);
Scheduler.unstable_advanceTime(100);
jest.advanceTimersByTime(100);
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="B" />
</>,
);
} else {
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="B" />
</>,
);
}
});
});
it('does not warn when a low priority update suspends inside a high priority update for functional components', async () => {
let _setShow;
function App() {
const [show, setShow] = React.useState(false);
_setShow = setShow;
return (
<Suspense fallback="Loading...">
{show && <AsyncText text="A" />}
</Suspense>
);
}
await act(() => {
ReactNoop.render(<App />);
});
await act(() => {
ReactNoop.flushSync(() => _setShow(true));
});
});
it('does not warn when a low priority update suspends inside a high priority update for class components', async () => {
let show;
class App extends React.Component {
state = {show: false};
render() {
show = () => this.setState({show: true});
return (
<Suspense fallback="Loading...">
{this.state.show && <AsyncText text="A" />}
</Suspense>
);
}
}
await act(() => {
ReactNoop.render(<App />);
});
await act(() => {
ReactNoop.flushSync(() => show());
});
});
it('does not warn about wrong Suspense priority if no new fallbacks are shown', async () => {
let showB;
class App extends React.Component {
state = {showB: false};
render() {
showB = () => this.setState({showB: true});
return (
<Suspense fallback="Loading...">
{<AsyncText text="A" />}
{this.state.showB && <AsyncText text="B" />}
</Suspense>
);
}
}
await act(() => {
ReactNoop.render(<App />);
});
assertLog([
'Suspend! [A]',
...(gate('enableSiblingPrerendering') ? ['Suspend! [A]'] : []),
]);
expect(ReactNoop).toMatchRenderedOutput('Loading...');
await act(() => {
ReactNoop.flushSync(() => showB());
});
assertLog([
'Suspend! [A]',
...(gate('enableSiblingPrerendering')
? ['Suspend! [A]', 'Suspend! [B]']
: []),
]);
});
it(
'does not warn when component that triggered user-blocking update is between Suspense boundary ' +
'and component that suspended',
async () => {
let _setShow;
function A() {
const [show, setShow] = React.useState(false);
_setShow = setShow;
return show && <AsyncText text="A" />;
}
function App() {
return (
<Suspense fallback="Loading...">
<A />
</Suspense>
);
}
await act(() => {
ReactNoop.render(<App />);
});
await act(() => {
ReactNoop.flushSync(() => _setShow(true));
});
},
);
it('normal priority updates suspending do not warn for class components', async () => {
let show;
class App extends React.Component {
state = {show: false};
render() {
show = () => this.setState({show: true});
return (
<Suspense fallback="Loading...">
{this.state.show && <AsyncText text="A" />}
</Suspense>
);
}
}
await act(() => {
ReactNoop.render(<App />);
});
await act(() => show(true));
assertLog([
'Suspend! [A]',
...(gate('enableSiblingPrerendering') ? ['Suspend! [A]'] : []),
]);
await resolveText('A');
expect(ReactNoop).toMatchRenderedOutput('Loading...');
});
it('normal priority updates suspending do not warn for functional components', async () => {
let _setShow;
function App() {
const [show, setShow] = React.useState(false);
_setShow = setShow;
return (
<Suspense fallback="Loading...">
{show && <AsyncText text="A" />}
</Suspense>
);
}
await act(() => {
ReactNoop.render(<App />);
});
await act(() => _setShow(true));
assertLog([
'Suspend! [A]',
...(gate('enableSiblingPrerendering') ? ['Suspend! [A]'] : []),
]);
await resolveText('A');
expect(ReactNoop).toMatchRenderedOutput('Loading...');
});
it('shows the parent fallback if the inner fallback should be avoided', async () => {
function Foo({showC}) {
Scheduler.log('Foo');
return (
<Suspense fallback={<Text text="Initial load..." />}>
<Suspense
unstable_avoidThisFallback={true}
fallback={<Text text="Updating..." />}>
<AsyncText text="A" />
{showC ? <AsyncText text="C" /> : null}
</Suspense>
<Text text="B" />
</Suspense>
);
}
ReactNoop.render(<Foo />);
await waitForAll([
'Foo',
'Suspend! [A]',
'Initial load...',
...(gate('enableSiblingPrerendering') ? ['Suspend! [A]', 'B'] : []),
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Initial load..." />);
await act(() => resolveText('A'));
assertLog(['A', 'B']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="B" />
</>,
);
ReactNoop.render(<Foo showC={true} />);
await waitForAll([
'Foo',
'A',
'Suspend! [C]',
'Updating...',
'B',
...(gate('enableSiblingPrerendering') ? ['A', 'Suspend! [C]'] : []),
]);
Scheduler.unstable_advanceTime(600);
await advanceTimers(600);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" hidden={true} />
<span prop="Updating..." />
<span prop="B" />
</>,
);
await act(() => resolveText('C'));
assertLog(['A', 'C']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="C" />
<span prop="B" />
</>,
);
});
it('does not show the parent fallback if the inner fallback is not defined', async () => {
function Foo({showC}) {
Scheduler.log('Foo');
return (
<Suspense fallback={<Text text="Initial load..." />}>
<Suspense>
<AsyncText text="A" />
{showC ? <AsyncText text="C" /> : null}
</Suspense>
<Text text="B" />
</Suspense>
);
}
ReactNoop.render(<Foo />);
await waitForAll([
'Foo',
'Suspend! [A]',
'B',
...(gate('enableSiblingPrerendering') ? ['Suspend! [A]'] : []),
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
await act(() => resolveText('A'));
assertLog(['A']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="B" />
</>,
);
ReactNoop.render(<Foo showC={true} />);
await waitForAll([
'Foo',
'A',
'Suspend! [C]',
'B',
...(gate('enableSiblingPrerendering') ? ['A', 'Suspend! [C]'] : []),
]);
Scheduler.unstable_advanceTime(600);
await advanceTimers(600);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" hidden={true} />
<span prop="B" />
</>,
);
await act(() => resolveText('C'));
assertLog(['A', 'C']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="C" />
<span prop="B" />
</>,
);
});
it('favors showing the inner fallback for nested top level avoided fallback', async () => {
function Foo({showB}) {
Scheduler.log('Foo');
return (
<Suspense
unstable_avoidThisFallback={true}
fallback={<Text text="Loading A..." />}>
<Text text="A" />
<Suspense
unstable_avoidThisFallback={true}
fallback={<Text text="Loading B..." />}>
<AsyncText text="B" />
</Suspense>
</Suspense>
);
}
ReactNoop.render(<Foo />);
await waitForAll([
'Foo',
'A',
'Suspend! [B]',
'Loading B...',
...(gate('enableSiblingPrerendering') ? ['Suspend! [B]'] : []),
]);
Scheduler.unstable_advanceTime(600);
await advanceTimers(600);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="Loading B..." />
</>,
);
});
it('keeps showing an avoided parent fallback if it is already showing', async () => {
function Foo({showB}) {
Scheduler.log('Foo');
return (
<Suspense fallback={<Text text="Initial load..." />}>
<Suspense
unstable_avoidThisFallback={true}
fallback={<Text text="Loading A..." />}>
<Text text="A" />
{showB ? (
<Suspense
unstable_avoidThisFallback={true}
fallback={<Text text="Loading B..." />}>
<AsyncText text="B" />
</Suspense>
) : null}
</Suspense>
</Suspense>
);
}
ReactNoop.render(<Foo />);
await waitForAll(['Foo', 'A']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
React.startTransition(() => {
ReactNoop.render(<Foo showB={true} />);
});
await waitForAll(['Foo', 'A', 'Suspend! [B]', 'Loading B...']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
});
it('keeps showing an undefined fallback if it is already showing', async () => {
function Foo({showB}) {
Scheduler.log('Foo');
return (
<Suspense fallback={<Text text="Initial load..." />}>
<Suspense fallback={undefined}>
<Text text="A" />
{showB ? (
<Suspense fallback={undefined}>
<AsyncText text="B" />
</Suspense>
) : null}
</Suspense>
</Suspense>
);
}
ReactNoop.render(<Foo />);
await waitForAll(['Foo', 'A']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
React.startTransition(() => {
ReactNoop.render(<Foo showB={true} />);
});
await waitForAll([
'Foo',
'A',
'Suspend! [B]',
...(gate('enableSiblingPrerendering') ? ['Suspend! [B]'] : []),
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
Scheduler.unstable_advanceTime(600);
await advanceTimers(600);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
});
describe('startTransition', () => {
it('top level render', async () => {
function App({page}) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={page} />
</Suspense>
);
}
React.startTransition(() => ReactNoop.render(<App page="A" />));
await waitForAll([
'Suspend! [A]',
'Loading...',
...(gate('enableSiblingPrerendering') ? ['Suspend! [A]'] : []),
]);
Scheduler.unstable_advanceTime(400);
await advanceTimers(400);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
await act(() => resolveText('A'));
assertLog(['A']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
React.startTransition(() => ReactNoop.render(<App page="B" />));
await waitForAll(['Suspend! [B]', 'Loading...']);
Scheduler.unstable_advanceTime(100000);
await advanceTimers(100000);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
await act(() => resolveText('B'));
assertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
});
it('hooks', async () => {
let transitionToPage;
function App() {
const [page, setPage] = React.useState('none');
transitionToPage = setPage;
if (page === 'none') {
return null;
}
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={page} />
</Suspense>
);
}
ReactNoop.render(<App />);
await waitForAll([]);
await act(async () => {
React.startTransition(() => transitionToPage('A'));
await waitForAll([
'Suspend! [A]',
'Loading...',
...(gate('enableSiblingPrerendering') ? ['Suspend! [A]'] : []),
]);
Scheduler.unstable_advanceTime(400);
await advanceTimers(400);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
});
await act(() => resolveText('A'));
assertLog(['A']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
await act(async () => {
React.startTransition(() => transitionToPage('B'));
await waitForAll(['Suspend! [B]', 'Loading...']);
Scheduler.unstable_advanceTime(100000);
await advanceTimers(100000);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
});
await act(() => resolveText('B'));
assertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
});
it('classes', async () => {
let transitionToPage;
class App extends React.Component {
state = {page: 'none'};
render() {
transitionToPage = page => this.setState({page});
const page = this.state.page;
if (page === 'none') {
return null;
}
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={page} />
</Suspense>
);
}
}
ReactNoop.render(<App />);
await waitForAll([]);
await act(async () => {
React.startTransition(() => transitionToPage('A'));
await waitForAll([
'Suspend! [A]',
'Loading...',
...(gate('enableSiblingPrerendering') ? ['Suspend! [A]'] : []),
]);
Scheduler.unstable_advanceTime(400);
await advanceTimers(400);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
});
await act(() => resolveText('A'));
assertLog(['A']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
await act(async () => {
React.startTransition(() => transitionToPage('B'));
await waitForAll(['Suspend! [B]', 'Loading...']);
Scheduler.unstable_advanceTime(100000);
await advanceTimers(100000);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
});
await act(() => resolveText('B'));
assertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
});
});
describe('delays transitions when using React.startTransition', () => {
it('top level render', async () => {
function App({page}) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={page} />
</Suspense>
);
}
React.startTransition(() => ReactNoop.render(<App page="A" />));
await waitForAll([
'Suspend! [A]',
'Loading...',
...(gate('enableSiblingPrerendering') ? ['Suspend! [A]'] : []),
]);
Scheduler.unstable_advanceTime(400);
await advanceTimers(400);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
await act(() => resolveText('A'));
assertLog(['A']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
React.startTransition(() => ReactNoop.render(<App page="B" />));
await waitForAll(['Suspend! [B]', 'Loading...']);
Scheduler.unstable_advanceTime(2999);
await advanceTimers(2999);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
await act(() => resolveText('B'));
assertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
React.startTransition(() => ReactNoop.render(<App page="C" />));
await waitForAll(['Suspend! [C]', 'Loading...']);
Scheduler.unstable_advanceTime(100000);
await advanceTimers(100000);
expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
});
it('hooks', async () => {
let transitionToPage;
function App() {
const [page, setPage] = React.useState('none');
transitionToPage = setPage;
if (page === 'none') {
return null;
}
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={page} />
</Suspense>
);
}
ReactNoop.render(<App />);
await waitForAll([]);
await act(async () => {
React.startTransition(() => transitionToPage('A'));
await waitForAll([
'Suspend! [A]',
'Loading...',
...(gate('enableSiblingPrerendering') ? ['Suspend! [A]'] : []),
]);
Scheduler.unstable_advanceTime(400);
await advanceTimers(400);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
});
await act(() => resolveText('A'));
assertLog(['A']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
await act(async () => {
React.startTransition(() => transitionToPage('B'));
await waitForAll(['Suspend! [B]', 'Loading...']);
Scheduler.unstable_advanceTime(2999);
await advanceTimers(2999);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
});
await act(() => resolveText('B'));
assertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
await act(async () => {
React.startTransition(() => transitionToPage('C'));
await waitForAll(['Suspend! [C]', 'Loading...']);
Scheduler.unstable_advanceTime(100000);
await advanceTimers(100000);
expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
});
});
it('classes', async () => {
let transitionToPage;
class App extends React.Component {
state = {page: 'none'};
render() {
transitionToPage = page => this.setState({page});
const page = this.state.page;
if (page === 'none') {
return null;
}
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={page} />
</Suspense>
);
}
}
ReactNoop.render(<App />);
await waitForAll([]);
await act(async () => {
React.startTransition(() => transitionToPage('A'));
await waitForAll([
'Suspend! [A]',
'Loading...',
...(gate('enableSiblingPrerendering') ? ['Suspend! [A]'] : []),
]);
Scheduler.unstable_advanceTime(400);
await advanceTimers(400);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
});
await act(() => resolveText('A'));
assertLog(['A']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
await act(async () => {
React.startTransition(() => transitionToPage('B'));
await waitForAll(['Suspend! [B]', 'Loading...']);
Scheduler.unstable_advanceTime(2999);
await advanceTimers(2999);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
});
await act(() => resolveText('B'));
assertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
await act(async () => {
React.startTransition(() => transitionToPage('C'));
await waitForAll(['Suspend! [C]', 'Loading...']);
Scheduler.unstable_advanceTime(100000);
await advanceTimers(100000);
expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
});
});
});
it('do not show placeholder when updating an avoided boundary with startTransition', async () => {
function App({page}) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<Text text="Hi!" />
<Suspense
fallback={<Text text={'Loading ' + page + '...'} />}
unstable_avoidThisFallback={true}>
<AsyncText text={page} />
</Suspense>
</Suspense>
);
}
ReactNoop.render(<App page="A" />);
await waitForAll([
'Hi!',
'Suspend! [A]',
'Loading...',
...(gate('enableSiblingPrerendering') ? ['Hi!', 'Suspend! [A]'] : []),
]);
await act(() => resolveText('A'));
assertLog(['Hi!', 'A']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Hi!" />
<span prop="A" />
</>,
);
React.startTransition(() => ReactNoop.render(<App page="B" />));
await waitForAll(['Hi!', 'Suspend! [B]', 'Loading B...']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Hi!" />
<span prop="A" />
</>,
);
Scheduler.unstable_advanceTime(1800);
await advanceTimers(1800);
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Hi!" />
<span prop="A" />
</>,
);
await resolveText('B');
await waitForAll(['Hi!', 'B']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Hi!" />
<span prop="B" />
</>,
);
});
it('do not show placeholder when mounting an avoided boundary with startTransition', async () => {
function App({page}) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<Text text="Hi!" />
{page === 'A' ? (
<Text text="A" />
) : (
<Suspense
fallback={<Text text={'Loading ' + page + '...'} />}
unstable_avoidThisFallback={true}>
<AsyncText text={page} />
</Suspense>
)}
</Suspense>
);
}
ReactNoop.render(<App page="A" />);
await waitForAll(['Hi!', 'A']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Hi!" />
<span prop="A" />
</>,
);
React.startTransition(() => ReactNoop.render(<App page="B" />));
await waitForAll(['Hi!', 'Suspend! [B]', 'Loading B...']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Hi!" />
<span prop="A" />
</>,
);
Scheduler.unstable_advanceTime(1800);
await advanceTimers(1800);
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Hi!" />
<span prop="A" />
</>,
);
await resolveText('B');
await waitForAll(['Hi!', 'B']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Hi!" />
<span prop="B" />
</>,
);
});
it('regression test: resets current "debug phase" after suspending', async () => {
function App() {
return (
<Suspense fallback="Loading...">
<Foo suspend={false} />
</Suspense>
);
}
const thenable = {then() {}};
let foo;
class Foo extends React.Component {
state = {suspend: false};
render() {
foo = this;
if (this.state.suspend) {
Scheduler.log('Suspend!');
throw thenable;
}
return <Text text="Foo" />;
}
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(<App />);
});
assertLog(['Foo']);
await act(async () => {
foo.setState({suspend: true});
await waitFor(['Suspend!']);
foo.setState({suspend: false});
});
assertLog([
'Foo',
]);
expect(root).toMatchRenderedOutput(<span prop="Foo" />);
});
it('should not render hidden content while suspended on higher pri', async () => {
function Offscreen() {
Scheduler.log('Offscreen');
return 'Offscreen';
}
function App({showContent}) {
React.useLayoutEffect(() => {
Scheduler.log('Commit');
});
return (
<>
<LegacyHiddenDiv mode="hidden">
<Offscreen />
</LegacyHiddenDiv>
<Suspense fallback={<Text text="Loading..." />}>
{showContent ? <AsyncText text="A" /> : null}
</Suspense>
</>
);
}
ReactNoop.render(<App showContent={false} />);
await waitFor(['Commit']);
expect(ReactNoop).toMatchRenderedOutput(<div hidden={true} />);
React.startTransition(() => {
ReactNoop.render(<App showContent={true} />);
});
await waitForAll(['Suspend! [A]', 'Loading...']);
await resolveText('A');
await waitFor(['A', 'Commit']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<div hidden={true} />
<span prop="A" />
</>,
);
await waitForAll(['Offscreen']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<div hidden={true}>Offscreen</div>
<span prop="A" />
</>,
);
});
it('should be able to unblock higher pri content before suspended hidden', async () => {
function Offscreen() {
Scheduler.log('Offscreen');
return 'Offscreen';
}
function App({showContent}) {
React.useLayoutEffect(() => {
Scheduler.log('Commit');
});
return (
<Suspense fallback={<Text text="Loading..." />}>
<LegacyHiddenDiv mode="hidden">
<AsyncText text="A" />
<Offscreen />
</LegacyHiddenDiv>
{showContent ? <AsyncText text="A" /> : null}
</Suspense>
);
}
ReactNoop.render(<App showContent={false} />);
await waitFor(['Commit']);
expect(ReactNoop).toMatchRenderedOutput(<div hidden={true} />);
await waitFor(['Suspend! [A]']);
React.startTransition(() => {
ReactNoop.render(<App showContent={true} />);
});
await waitForAll(['Suspend! [A]', 'Loading...']);
await resolveText('A');
await waitFor(['A', 'Commit']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<div hidden={true} />
<span prop="A" />
</>,
);
await waitForAll(['A', 'Offscreen']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<div hidden={true}>
<span prop="A" />
Offscreen
</div>
<span prop="A" />
</>,
);
});
it(
'multiple updates originating inside a Suspense boundary at different ' +
'priority levels are not dropped',
async () => {
const {useState} = React;
const root = ReactNoop.createRoot();
function Parent() {
return (
<>
<Suspense fallback={<Text text="Loading..." />}>
<Child />
</Suspense>
</>
);
}
let setText;
function Child() {
const [text, _setText] = useState('A');
setText = _setText;
return <AsyncText text={text} />;
}
await seedNextTextCache('A');
await act(() => {
root.render(<Parent />);
});
assertLog(['A']);
expect(root).toMatchRenderedOutput(<span prop="A" />);
await act(async () => {
ReactNoop.discreteUpdates(() => {
setText('B');
});
startTransition(() => {
setText('C');
});
assertLog([]);
await resolveText('C');
});
assertLog([
'Suspend! [B]',
'Loading...',
'C',
]);
expect(root).toMatchRenderedOutput(<span prop="C" />);
},
);
it(
'multiple updates originating inside a Suspense boundary at different ' +
'priority levels are not dropped, including Idle updates',
async () => {
const {useState} = React;
const root = ReactNoop.createRoot();
function Parent() {
return (
<>
<Suspense fallback={<Text text="Loading..." />}>
<Child />
</Suspense>
</>
);
}
let setText;
function Child() {
const [text, _setText] = useState('A');
setText = _setText;
return <AsyncText text={text} />;
}
await seedNextTextCache('A');
await act(() => {
root.render(<Parent />);
});
assertLog(['A']);
expect(root).toMatchRenderedOutput(<span prop="A" />);
await act(async () => {
setText('B');
await resolveText('C');
ReactNoop.idleUpdates(() => {
setText('C');
});
await waitForPaint(['Suspend! [B]', 'Loading...']);
expect(root).toMatchRenderedOutput(
<>
<span hidden={true} prop="A" />
<span prop="Loading..." />
</>,
);
await waitForAll(['C']);
expect(root).toMatchRenderedOutput(<span prop="C" />);
});
},
);
it(
'fallback component can update itself even after a high pri update to ' +
'the primary tree suspends',
async () => {
const {useState} = React;
const root = ReactNoop.createRoot();
let setAppText;
function App() {
const [text, _setText] = useState('A');
setAppText = _setText;
return (
<>
<Suspense fallback={<Fallback />}>
<AsyncText text={text} />
</Suspense>
</>
);
}
let setFallbackText;
function Fallback() {
const [text, _setText] = useState('Loading...');
setFallbackText = _setText;
return <Text text={text} />;
}
await seedNextTextCache('A');
await act(() => {
root.render(<App />);
});
assertLog(['A']);
expect(root).toMatchRenderedOutput(<span prop="A" />);
await act(async () => {
setAppText('B');
await waitForAll([
'Suspend! [B]',
'Loading...',
...(gate('enableSiblingPrerendering') ? ['Suspend! [B]'] : []),
]);
});
expect(root).toMatchRenderedOutput(
<>
<span hidden={true} prop="A" />
<span prop="Loading..." />
</>,
);
await act(() => {
setAppText('C');
React.startTransition(() => {
setFallbackText('Still loading...');
});
});
assertLog([
'Suspend! [C]',
'Loading...',
'Suspend! [C]',
'Still loading...',
...(gate('enableSiblingPrerendering') ? ['Suspend! [C]'] : []),
]);
expect(root).toMatchRenderedOutput(
<>
<span hidden={true} prop="A" />
<span prop="Still loading..." />
</>,
);
},
);
it(
'regression: primary fragment fiber is not always part of setState ' +
'return path',
async () => {
const {useState} = React;
const root = ReactNoop.createRoot();
function Parent() {
return (
<>
<Suspense fallback={<Text text="Loading..." />}>
<Child />
</Suspense>
</>
);
}
let setText;
function Child() {
const [text, _setText] = useState('A');
setText = _setText;
return <AsyncText text={text} />;
}
await seedNextTextCache('A');
await act(() => {
root.render(<Parent />);
});
assertLog(['A']);
expect(root).toMatchRenderedOutput(<span prop="A" />);
await resolveText('B');
await act(() => {
setText('B');
});
assertLog(['B']);
expect(root).toMatchRenderedOutput(<span prop="B" />);
await act(() => {
setText('C');
});
assertLog([
'Suspend! [C]',
'Loading...',
...(gate('enableSiblingPrerendering') ? ['Suspend! [C]'] : []),
]);
await act(async () => {
await advanceTimers(250);
});
expect(root).toMatchRenderedOutput(
<>
<span hidden={true} prop="B" />
<span prop="Loading..." />
</>,
);
await resolveText('D');
await act(() => {
setText('D');
});
assertLog(['D']);
expect(root).toMatchRenderedOutput(<span prop="D" />);
},
);
it(
'regression: primary fragment fiber is not always part of setState ' +
'return path (another case)',
async () => {
const {useState} = React;
const root = ReactNoop.createRoot();
function Parent() {
return (
<Suspense fallback={<Text text="Loading..." />}>
<Child />
</Suspense>
);
}
let setText;
function Child() {
const [text, _setText] = useState('A');
setText = _setText;
return <AsyncText text={text} />;
}
await seedNextTextCache('A');
await act(() => {
root.render(<Parent />);
});
assertLog(['A']);
expect(root).toMatchRenderedOutput(<span prop="A" />);
await resolveText('B');
await act(() => {
setText('B');
});
assertLog(['B']);
expect(root).toMatchRenderedOutput(<span prop="B" />);
await act(() => {
setText('C');
});
assertLog([
'Suspend! [C]',
'Loading...',
...(gate('enableSiblingPrerendering') ? ['Suspend! [C]'] : []),
]);
await act(async () => {
await advanceTimers(250);
});
expect(root).toMatchRenderedOutput(
<>
<span hidden={true} prop="B" />
<span prop="Loading..." />
</>,
);
await act(async () => {
setText('D');
await resolveText('E');
ReactNoop.idleUpdates(() => {
setText('E');
});
});
assertLog([
'Suspend! [D]',
...(gate('enableSiblingPrerendering') ? ['Suspend! [D]'] : []),
'E',
]);
expect(root).toMatchRenderedOutput(<span prop="E" />);
},
);
it(
'after showing fallback, should not flip back to primary content until ' +
'the update that suspended finishes',
async () => {
const {useState, useEffect} = React;
const root = ReactNoop.createRoot();
let setOuterText;
function Parent({step}) {
const [text, _setText] = useState('A');
setOuterText = _setText;
return (
<>
<Text text={'Outer text: ' + text} />
<Text text={'Outer step: ' + step} />
<Suspense fallback={<Text text="Loading..." />}>
<Child step={step} outerText={text} />
</Suspense>
</>
);
}
let setInnerText;
function Child({step, outerText}) {
const [text, _setText] = useState('A');
setInnerText = _setText;
useEffect(() => {
if (text === outerText) {
Scheduler.log('Commit Child');
} else {
Scheduler.log('FIXME: Texts are inconsistent (tearing)');
}
}, [text, outerText]);
return (
<>
<AsyncText text={'Inner text: ' + text} />
<Text text={'Inner step: ' + step} />
</>
);
}
function setText(text) {
setOuterText(text);
setInnerText(text);
}
await seedNextTextCache('Inner text: A');
await act(() => {
root.render(<Parent step={0} />);
});
assertLog([
'Outer text: A',
'Outer step: 0',
'Inner text: A',
'Inner step: 0',
'Commit Child',
]);
expect(root).toMatchRenderedOutput(
<>
<span prop="Outer text: A" />
<span prop="Outer step: 0" />
<span prop="Inner text: A" />
<span prop="Inner step: 0" />
</>,
);
await act(() => {
setText('B');
});
assertLog([
'Outer text: B',
'Outer step: 0',
'Suspend! [Inner text: B]',
'Loading...',
...(gate('enableSiblingPrerendering')
? ['Suspend! [Inner text: B]', 'Inner step: 0']
: []),
]);
await advanceTimers(250);
expect(root).toMatchRenderedOutput(
<>
<span prop="Outer text: B" />
<span prop="Outer step: 0" />
<span hidden={true} prop="Inner text: A" />
<span hidden={true} prop="Inner step: 0" />
<span prop="Loading..." />
</>,
);
await act(() => {
ReactNoop.discreteUpdates(() => {
root.render(<Parent step={1} />);
});
});
assertLog([
'Outer text: B',
'Outer step: 1',
'Suspend! [Inner text: B]',
'Loading...',
...(gate('enableSiblingPrerendering')
? ['Suspend! [Inner text: B]', 'Inner step: 1']
: []),
]);
expect(root).toMatchRenderedOutput(
<>
<span prop="Outer text: B" />
<span prop="Outer step: 1" />
<span hidden={true} prop="Inner text: A" />
<span hidden={true} prop="Inner step: 0" />
<span prop="Loading..." />
</>,
);
await act(async () => {
await resolveText('Inner text: B');
});
assertLog(['Inner text: B', 'Inner step: 1', 'Commit Child']);
expect(root).toMatchRenderedOutput(
<>
<span prop="Outer text: B" />
<span prop="Outer step: 1" />
<span prop="Inner text: B" />
<span prop="Inner step: 1" />
</>,
);
},
);
it('a high pri update can unhide a boundary that suspended at a different level', async () => {
const {useState, useEffect} = React;
const root = ReactNoop.createRoot();
let setOuterText;
function Parent({step}) {
const [text, _setText] = useState('A');
setOuterText = _setText;
return (
<>
<Text text={'Outer: ' + text + step} />
<Suspense fallback={<Text text="Loading..." />}>
<Child step={step} outerText={text} />
</Suspense>
</>
);
}
let setInnerText;
function Child({step, outerText}) {
const [text, _setText] = useState('A');
setInnerText = _setText;
useEffect(() => {
if (text === outerText) {
Scheduler.log('Commit Child');
} else {
Scheduler.log('FIXME: Texts are inconsistent (tearing)');
}
}, [text, outerText]);
return (
<>
<AsyncText text={'Inner: ' + text + step} />
</>
);
}
function setText(text) {
setOuterText(text);
setInnerText(text);
}
await seedNextTextCache('Inner: A0');
await act(() => {
root.render(<Parent step={0} />);
});
assertLog(['Outer: A0', 'Inner: A0', 'Commit Child']);
expect(root).toMatchRenderedOutput(
<>
<span prop="Outer: A0" />
<span prop="Inner: A0" />
</>,
);
await act(() => {
setText('B');
});
assertLog([
'Outer: B0',
'Suspend! [Inner: B0]',
'Loading...',
...(gate('enableSiblingPrerendering') ? ['Suspend! [Inner: B0]'] : []),
]);
await advanceTimers(250);
expect(root).toMatchRenderedOutput(
<>
<span prop="Outer: B0" />
<span hidden={true} prop="Inner: A0" />
<span prop="Loading..." />
</>,
);
await resolveText('Inner: B1');
await act(() => {
ReactNoop.discreteUpdates(() => {
root.render(<Parent step={1} />);
});
});
assertLog(['Outer: B1', 'Inner: B1', 'Commit Child']);
expect(root).toMatchRenderedOutput(
<>
<span prop="Outer: B1" />
<span prop="Inner: B1" />
</>,
);
});
it('regression: ping at high priority causes update to be dropped', async () => {
const {useState, useTransition} = React;
let setTextA;
function A() {
const [textA, _setTextA] = useState('A');
setTextA = _setTextA;
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={textA} />
</Suspense>
);
}
let setTextB;
let startTransitionFromB;
function B() {
const [textB, _setTextB] = useState('B');
const [_, _startTransition] = useTransition();
startTransitionFromB = _startTransition;
setTextB = _setTextB;
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={textB} />
</Suspense>
);
}
function App() {
return (
<>
<A />
<B />
</>
);
}
const root = ReactNoop.createRoot();
await act(async () => {
await seedNextTextCache('A');
await seedNextTextCache('B');
root.render(<App />);
});
assertLog(['A', 'B']);
expect(root).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="B" />
</>,
);
await act(async () => {
setTextA('A1');
startTransitionFromB(() => {
setTextA('A2');
setTextB('B2');
});
await waitFor([
'Suspend! [A1]',
'Loading...',
'B',
'Suspend! [A2]',
'Loading...',
'Suspend! [B2]',
'Loading...',
]);
expect(root).toMatchRenderedOutput(
<>
<span hidden={true} prop="A" />
<span prop="Loading..." />
<span prop="B" />
</>,
);
await resolveText('A1');
await waitFor(['A1']);
});
assertLog(['Suspend! [A2]', 'Loading...', 'Suspend! [B2]', 'Loading...']);
expect(root).toMatchRenderedOutput(
<>
<span prop="A1" />
<span prop="B" />
</>,
);
await act(async () => {
await resolveText('A2');
await resolveText('B2');
});
assertLog(['A2', 'B2']);
expect(root).toMatchRenderedOutput(
<>
<span prop="A2" />
<span prop="B2" />
</>,
);
});
it('does not get stuck in pending state with render phase updates', async () => {
let setTextWithShortTransition;
let setTextWithLongTransition;
function App() {
const [isPending1, startShortTransition] = React.useTransition();
const [isPending2, startLongTransition] = React.useTransition();
const isPending = isPending1 || isPending2;
const [text, setText] = React.useState('');
const [mirror, setMirror] = React.useState('');
if (text !== mirror) {
setMirror(text);
}
setTextWithShortTransition = value => {
startShortTransition(() => {
setText(value);
});
};
setTextWithLongTransition = value => {
startLongTransition(() => {
setText(value);
});
};
return (
<>
{isPending ? <Text text="Pending..." /> : null}
{text !== '' ? <AsyncText text={text} /> : <Text text={text} />}
</>
);
}
function Root() {
return (
<Suspense fallback={<Text text="Loading..." />}>
<App />
</Suspense>
);
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(<Root />);
});
assertLog(['']);
expect(root).toMatchRenderedOutput(<span prop="" />);
await act(async () => {
setTextWithShortTransition('a');
await waitForAll(['Pending...', '', 'Suspend! [a]', 'Loading...']);
});
assertLog([]);
expect(root).toMatchRenderedOutput(
<>
<span prop="Pending..." />
<span prop="" />
</>,
);
await act(async () => {
setTextWithLongTransition('b');
await waitForAll([
'Pending...',
'',
'Suspend! [b]',
'Loading...',
]);
});
assertLog([]);
expect(root).toMatchRenderedOutput(
<>
<span prop="Pending..." />
<span prop="" />
</>,
);
await act(async () => {
await resolveText('a');
await waitForAll(['Suspend! [b]', 'Loading...']);
expect(root).toMatchRenderedOutput(
<>
<span prop="Pending..." />
<span prop="" />
</>,
);
await act(async () => {
await resolveText('b');
});
assertLog(['b']);
expect(root).toMatchRenderedOutput(<span prop="b" />);
});
});
it('regression related to Idle updates (outdated experiment): #18657', async () => {
const {useState} = React;
let setText;
function App() {
const [text, _setText] = useState('A');
setText = _setText;
return <AsyncText text={text} />;
}
const root = ReactNoop.createRoot();
await act(async () => {
await seedNextTextCache('A');
root.render(
<Suspense fallback={<Text text="Loading..." />}>
<App />
</Suspense>,
);
});
assertLog(['A']);
expect(root).toMatchRenderedOutput(<span prop="A" />);
await act(async () => {
setText('B');
ReactNoop.idleUpdates(() => {
setText('C');
});
await waitForPaint(['Suspend! [B]', 'Loading...']);
expect(root).toMatchRenderedOutput(
<>
<span hidden={true} prop="A" />
<span prop="Loading..." />
</>,
);
await waitForAll(['Suspend! [C]']);
});
await act(async () => {
setText('B');
await resolveText('B');
});
assertLog(['B']);
expect(root).toMatchRenderedOutput(<span prop="B" />);
await act(async () => {
setText('C');
await resolveText('C');
});
assertLog(['C']);
expect(root).toMatchRenderedOutput(<span prop="C" />);
});
it('retries have lower priority than normal updates', async () => {
const {useState} = React;
let setText;
function UpdatingText() {
const [text, _setText] = useState('A');
setText = _setText;
return <Text text={text} />;
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(
<>
<UpdatingText />
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Async" />
</Suspense>
</>,
);
});
assertLog([
'A',
'Suspend! [Async]',
'Loading...',
...(gate('enableSiblingPrerendering') ? ['Suspend! [Async]'] : []),
]);
expect(root).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="Loading..." />
</>,
);
await act(async () => {
await resolveText('Async');
setText('B');
await waitForPaint(['B']);
expect(root).toMatchRenderedOutput(
<>
<span prop="B" />
<span prop="Loading..." />
</>,
);
});
assertLog(['Async']);
expect(root).toMatchRenderedOutput(
<>
<span prop="B" />
<span prop="Async" />
</>,
);
});
it('should fire effect clean-up when deleting suspended tree', async () => {
const {useEffect} = React;
function App({show}) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<Child />
{show && <AsyncText text="Async" />}
</Suspense>
);
}
function Child() {
useEffect(() => {
Scheduler.log('Mount Child');
return () => {
Scheduler.log('Unmount Child');
};
}, []);
return <span prop="Child" />;
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(<App show={false} />);
});
assertLog(['Mount Child']);
expect(root).toMatchRenderedOutput(<span prop="Child" />);
await act(() => {
root.render(<App show={true} />);
});
assertLog([
'Suspend! [Async]',
'Loading...',
...(gate('enableSiblingPrerendering') ? ['Suspend! [Async]'] : []),
]);
expect(root).toMatchRenderedOutput(
<>
<span hidden={true} prop="Child" />
<span prop="Loading..." />
</>,
);
await act(() => {
root.render(null);
});
assertLog(['Unmount Child']);
});
it('should fire effect clean-up when deleting suspended tree (legacy)', async () => {
const {useEffect} = React;
function App({show}) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<Child />
{show && <AsyncText text="Async" />}
</Suspense>
);
}
function Child() {
useEffect(() => {
Scheduler.log('Mount Child');
return () => {
Scheduler.log('Unmount Child');
};
}, []);
return <span prop="Child" />;
}
const root = ReactNoop.createLegacyRoot();
await act(() => {
root.render(<App show={false} />);
});
assertLog(['Mount Child']);
expect(root).toMatchRenderedOutput(<span prop="Child" />);
await act(() => {
root.render(<App show={true} />);
});
assertLog(['Suspend! [Async]', 'Loading...']);
expect(root).toMatchRenderedOutput(
<>
<span hidden={true} prop="Child" />
<span prop="Loading..." />
</>,
);
await act(() => {
root.render(null);
});
assertLog(['Unmount Child']);
});
it(
'regression test: pinging synchronously within the render phase ' +
'does not unwind the stack',
async () => {
const thenable = {
then(resolve) {
resolve('B');
},
status: 'pending',
};
function ImmediatelyPings() {
if (thenable.status === 'pending') {
thenable.status = 'fulfilled';
throw thenable;
}
return <Text text="B" />;
}
function App({showMore}) {
return (
<div>
<Suspense fallback={<Text text="Loading A..." />}>
{showMore ? (
<>
<AsyncText text="A" />
</>
) : null}
</Suspense>
{showMore ? (
<Suspense fallback={<Text text="Loading B..." />}>
<ImmediatelyPings />
</Suspense>
) : null}
</div>
);
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(<App showMore={false} />);
});
assertLog([]);
expect(root).toMatchRenderedOutput(<div />);
await act(() => {
startTransition(() => root.render(<App showMore={true} />));
});
assertLog(['Suspend! [A]', 'Loading A...', 'Loading B...']);
expect(root).toMatchRenderedOutput(<div />);
},
);
it('recurring updates in siblings should not block expensive content in suspense boundary from committing', async () => {
const {useState} = React;
let setText;
function UpdatingText() {
const [text, _setText] = useState('1');
setText = _setText;
return <Text text={text} />;
}
function ExpensiveText({text, ms}) {
Scheduler.log(text);
Scheduler.unstable_advanceTime(ms);
return <span prop={text} />;
}
function App() {
return (
<>
<UpdatingText />
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Async" />
<ExpensiveText text="A" ms={1000} />
<ExpensiveText text="B" ms={3999} />
<ExpensiveText text="C" ms={100000} />
</Suspense>
</>
);
}
const root = ReactNoop.createRoot();
root.render(<App />);
await waitForAll([
'1',
'Suspend! [Async]',
'Loading...',
...(gate('enableSiblingPrerendering')
? ['Suspend! [Async]', 'A', 'B', 'C']
: []),
]);
expect(root).toMatchRenderedOutput(
<>
<span prop="1" />
<span prop="Loading..." />
</>,
);
await resolveText('Async');
expect(root).toMatchRenderedOutput(
<>
<span prop="1" />
<span prop="Loading..." />
</>,
);
await waitFor(['Async', 'A', 'B']);
ReactNoop.expire(100000);
await advanceTimers(100000);
setText('2');
await waitForPaint(['2']);
await waitForMicrotasks();
Scheduler.unstable_flushNumberOfYields(1);
assertLog(['Async', 'A', 'B', 'C']);
expect(root).toMatchRenderedOutput(
<>
<span prop="2" />
<span prop="Async" />
<span prop="A" />
<span prop="B" />
<span prop="C" />
</>,
);
});
});