/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
* @jest-environment node
*/
let Profiler;
let React;
let ReactNoop;
let Scheduler;
let ReactFeatureFlags;
let ReactCache;
let Suspense;
let TextResource;
let textResourceShouldFail;
let waitForAll;
let assertLog;
let act;
describe('ReactSuspensePlaceholder', () => {
beforeEach(() => {
jest.resetModules();
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.enableProfilerTimer = true;
ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
React = require('react');
ReactNoop = require('react-noop-renderer');
Scheduler = require('scheduler');
ReactCache = require('react-cache');
Profiler = React.Profiler;
Suspense = React.Suspense;
const InternalTestUtils = require('internal-test-utils');
waitForAll = InternalTestUtils.waitForAll;
assertLog = InternalTestUtils.assertLog;
act = InternalTestUtils.act;
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.log(`Promise rejected [${text}]`);
status = 'rejected';
value = new Error('Failed to load: ' + text);
listeners.forEach(listener => listener.reject(value));
} else {
Scheduler.log(`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({fakeRenderDuration = 0, text = 'Text'}) {
Scheduler.unstable_advanceTime(fakeRenderDuration);
Scheduler.log(text);
return text;
}
function AsyncText({fakeRenderDuration = 0, ms, text}) {
Scheduler.unstable_advanceTime(fakeRenderDuration);
try {
TextResource.read([text, ms]);
Scheduler.log(text);
return text;
} catch (promise) {
if (typeof promise.then === 'function') {
Scheduler.log(`Suspend! [${text}]`);
} else {
Scheduler.log(`Error! [${text}]`);
}
throw promise;
}
}
it('times out children that are already hidden', async () => {
class HiddenText extends React.PureComponent {
render() {
const text = this.props.text;
Scheduler.log(text);
return <span hidden={true}>{text}</span>;
}
}
function App(props) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<HiddenText text="A" />
<span>
<AsyncText ms={1000} text={props.middleText} />
</span>
<span>
<Text text="C" />
</span>
</Suspense>
);
}
// Initial mount
ReactNoop.render(<App middleText="B" />);
await waitForAll(['A', 'Suspend! [B]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput('Loading...');
await act(() => jest.advanceTimersByTime(1000));
assertLog(['Promise resolved [B]', 'A', 'B', 'C']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span hidden={true}>A</span>
<span>B</span>
<span>C</span>
</>,
);
// Update
ReactNoop.render(<App middleText="B2" />);
await waitForAll(['Suspend! [B2]', 'Loading...']);
// Time out the update
jest.advanceTimersByTime(750);
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span hidden={true}>A</span>
<span hidden={true}>B</span>
<span hidden={true}>C</span>
Loading...
</>,
);
// Resolve the promise
await act(() => jest.advanceTimersByTime(1000));
assertLog(['Promise resolved [B2]', 'B2', 'C']);
// Render the final update. A should still be hidden, because it was
// given a `hidden` prop.
expect(ReactNoop).toMatchRenderedOutput(
<>
<span hidden={true}>A</span>
<span>B2</span>
<span>C</span>
</>,
);
});
it('times out text nodes', async () => {
function App(props) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<Text text="A" />
<AsyncText ms={1000} text={props.middleText} />
<Text text="C" />
</Suspense>
);
}
// Initial mount
ReactNoop.render(<App middleText="B" />);
await waitForAll(['A', 'Suspend! [B]', 'Loading...']);
expect(ReactNoop).not.toMatchRenderedOutput('ABC');
await act(() => jest.advanceTimersByTime(1000));
assertLog(['Promise resolved [B]', 'A', 'B', 'C']);
expect(ReactNoop).toMatchRenderedOutput('ABC');
// Update
ReactNoop.render(<App middleText="B2" />);
await waitForAll(['A', 'Suspend! [B2]', 'Loading...']);
// Time out the update
jest.advanceTimersByTime(750);
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput('Loading...');
// Resolve the promise
await act(() => jest.advanceTimersByTime(1000));
assertLog(['Promise resolved [B2]', 'A', 'B2', 'C']);
// Render the final update. A should still be hidden, because it was
// given a `hidden` prop.
expect(ReactNoop).toMatchRenderedOutput('AB2C');
});
it('preserves host context for text nodes', async () => {
function App(props) {
return (
// uppercase is a special type that causes React Noop to render child
// text nodes as uppercase.
<uppercase>
<Suspense fallback={<Text text="Loading..." />}>
<Text text="a" />
<AsyncText ms={1000} text={props.middleText} />
<Text text="c" />
</Suspense>
</uppercase>
);
}
// Initial mount
ReactNoop.render(<App middleText="b" />);
await waitForAll(['a', 'Suspend! [b]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(<uppercase>LOADING...</uppercase>);
await act(() => jest.advanceTimersByTime(1000));
assertLog(['Promise resolved [b]', 'a', 'b', 'c']);
expect(ReactNoop).toMatchRenderedOutput(<uppercase>ABC</uppercase>);
// Update
ReactNoop.render(<App middleText="b2" />);
await waitForAll(['a', 'Suspend! [b2]', 'Loading...']);
// Time out the update
jest.advanceTimersByTime(750);
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput(<uppercase>LOADING...</uppercase>);
// Resolve the promise
await act(() => jest.advanceTimersByTime(1000));
assertLog(['Promise resolved [b2]', 'a', 'b2', 'c']);
// Render the final update. A should still be hidden, because it was
// given a `hidden` prop.
expect(ReactNoop).toMatchRenderedOutput(<uppercase>AB2C</uppercase>);
});
describe('profiler durations', () => {
let App;
let onRender;
beforeEach(() => {
// Order of parameters: id, phase, actualDuration, treeBaseDuration
onRender = jest.fn();
const Fallback = () => {
Scheduler.log('Fallback');
Scheduler.unstable_advanceTime(10);
return 'Loading...';
};
const Suspending = () => {
Scheduler.log('Suspending');
Scheduler.unstable_advanceTime(2);
return <AsyncText ms={1000} text="Loaded" fakeRenderDuration={1} />;
};
App = ({shouldSuspend, text = 'Text', textRenderDuration = 5}) => {
Scheduler.log('App');
return (
<Profiler id="root" onRender={onRender}>
<Suspense fallback={<Fallback />}>
{shouldSuspend && <Suspending />}
<Text fakeRenderDuration={textRenderDuration} text={text} />
</Suspense>
</Profiler>
);
};
});
describe('when suspending during mount', () => {
it('properly accounts for base durations when a suspended times out in a legacy tree', async () => {
ReactNoop.renderLegacySyncRoot(<App shouldSuspend={true} />);
assertLog([
'App',
'Suspending',
'Suspend! [Loaded]',
'Text',
'Fallback',
]);
expect(ReactNoop).toMatchRenderedOutput('Loading...');
expect(onRender).toHaveBeenCalledTimes(1);
// Initial mount only shows the "Loading..." Fallback.
// The treeBaseDuration then should be 10ms spent rendering Fallback,
// but the actualDuration should also include the 8ms spent rendering the hidden tree.
expect(onRender.mock.calls[0][2]).toBe(18);
expect(onRender.mock.calls[0][3]).toBe(10);
jest.advanceTimersByTime(1000);
assertLog(['Promise resolved [Loaded]']);
ReactNoop.flushSync();
assertLog(['Loaded']);
expect(ReactNoop).toMatchRenderedOutput('LoadedText');
expect(onRender).toHaveBeenCalledTimes(2);
// When the suspending data is resolved and our final UI is rendered,
// the baseDuration should only include the 1ms re-rendering AsyncText,
// but the treeBaseDuration should include the full 8ms spent in the tree.
expect(onRender.mock.calls[1][2]).toBe(1);
expect(onRender.mock.calls[1][3]).toBe(8);
});
it('properly accounts for base durations when a suspended times out in a concurrent tree', async () => {
ReactNoop.render(<App shouldSuspend={true} />);
await waitForAll([
'App',
'Suspending',
'Suspend! [Loaded]',
'Fallback',
]);
// Since this is initial render we immediately commit the fallback. Another test below
// deals with the update case where this suspends.
expect(ReactNoop).toMatchRenderedOutput('Loading...');
expect(onRender).toHaveBeenCalledTimes(1);
// Initial mount only shows the "Loading..." Fallback.
// The treeBaseDuration then should be 10ms spent rendering Fallback,
// but the actualDuration should also include the 3ms spent rendering the hidden tree.
expect(onRender.mock.calls[0][2]).toBe(13);
expect(onRender.mock.calls[0][3]).toBe(10);
// Resolve the pending promise.
await act(() => jest.advanceTimersByTime(1000));
assertLog([
'Promise resolved [Loaded]',
'Suspending',
'Loaded',
'Text',
]);
expect(ReactNoop).toMatchRenderedOutput('LoadedText');
expect(onRender).toHaveBeenCalledTimes(2);
// When the suspending data is resolved and our final UI is rendered,
// both times should include the 8ms re-rendering Suspending and AsyncText.
expect(onRender.mock.calls[1][2]).toBe(8);
expect(onRender.mock.calls[1][3]).toBe(8);
});
});
describe('when suspending during update', () => {
it('properly accounts for base durations when a suspended times out in a legacy tree', async () => {
ReactNoop.renderLegacySyncRoot(
<App shouldSuspend={false} textRenderDuration={5} />,
);
assertLog(['App', 'Text']);
expect(ReactNoop).toMatchRenderedOutput('Text');
expect(onRender).toHaveBeenCalledTimes(1);
// Initial mount only shows the "Text" text.
// It should take 5ms to render.
expect(onRender.mock.calls[0][2]).toBe(5);
expect(onRender.mock.calls[0][3]).toBe(5);
ReactNoop.render(<App shouldSuspend={true} textRenderDuration={5} />);
assertLog([
'App',
'Suspending',
'Suspend! [Loaded]',
'Text',
'Fallback',
]);
expect(ReactNoop).toMatchRenderedOutput('Loading...');
expect(onRender).toHaveBeenCalledTimes(2);
// The suspense update should only show the "Loading..." Fallback.
// The actual duration should include 10ms spent rendering Fallback,
// plus the 8ms render all of the hidden, suspended subtree.
// But the tree base duration should only include 10ms spent rendering Fallback,
expect(onRender.mock.calls[1][2]).toBe(18);
expect(onRender.mock.calls[1][3]).toBe(10);
ReactNoop.renderLegacySyncRoot(
<App shouldSuspend={true} text="New" textRenderDuration={6} />,
);
assertLog([
'App',
'Suspending',
'Suspend! [Loaded]',
'New',
'Fallback',
]);
expect(ReactNoop).toMatchRenderedOutput('Loading...');
expect(onRender).toHaveBeenCalledTimes(3);
expect(onRender.mock.calls[1][2]).toBe(18);
expect(onRender.mock.calls[1][3]).toBe(10);
jest.advanceTimersByTime(1000);
assertLog(['Promise resolved [Loaded]']);
ReactNoop.flushSync();
assertLog(['Loaded']);
expect(ReactNoop).toMatchRenderedOutput('LoadedNew');
expect(onRender).toHaveBeenCalledTimes(4);
// When the suspending data is resolved and our final UI is rendered,
// the baseDuration should only include the 1ms re-rendering AsyncText,
// but the treeBaseDuration should include the full 9ms spent in the tree.
expect(onRender.mock.calls[3][2]).toBe(1);
expect(onRender.mock.calls[3][3]).toBe(9);
});
it('properly accounts for base durations when a suspended times out in a concurrent tree', async () => {
const Fallback = () => {
Scheduler.log('Fallback');
Scheduler.unstable_advanceTime(10);
return 'Loading...';
};
const Suspending = () => {
Scheduler.log('Suspending');
Scheduler.unstable_advanceTime(2);
return <AsyncText ms={1000} text="Loaded" fakeRenderDuration={1} />;
};
App = ({shouldSuspend, text = 'Text', textRenderDuration = 5}) => {
Scheduler.log('App');
return (
<Profiler id="root" onRender={onRender}>
<Suspense fallback={<Fallback />}>
{shouldSuspend && <Suspending />}
<Text fakeRenderDuration={textRenderDuration} text={text} />
</Suspense>
</Profiler>
);
};
ReactNoop.render(
<>
<App shouldSuspend={false} textRenderDuration={5} />
<Suspense fallback={null} />
</>,
);
await waitForAll(['App', 'Text']);
expect(ReactNoop).toMatchRenderedOutput('Text');
expect(onRender).toHaveBeenCalledTimes(1);
// Initial mount only shows the "Text" text.
// It should take 5ms to render.
expect(onRender.mock.calls[0][2]).toBe(5);
expect(onRender.mock.calls[0][3]).toBe(5);
ReactNoop.render(
<>
<App shouldSuspend={true} textRenderDuration={5} />
<Suspense fallback={null} />
</>,
);
await waitForAll([
'App',
'Suspending',
'Suspend! [Loaded]',
'Fallback',
]);
// Show the fallback UI.
expect(ReactNoop).toMatchRenderedOutput('Loading...');
expect(onRender).toHaveBeenCalledTimes(2);
jest.advanceTimersByTime(900);
// The suspense update should only show the "Loading..." Fallback.
// The actual duration should include 10ms spent rendering Fallback,
// plus the 3ms render all of the partially rendered suspended subtree.
// But the tree base duration should only include 10ms spent rendering Fallback.
expect(onRender.mock.calls[1][2]).toBe(13);
expect(onRender.mock.calls[1][3]).toBe(10);
// Update again while timed out.
// Since this test was originally written we added an optimization to avoid
// suspending in the case that we already timed out. To simulate the old
// behavior, we add a different suspending boundary as a sibling.
ReactNoop.render(
<>
<App shouldSuspend={true} text="New" textRenderDuration={6} />
<Suspense fallback={null}>
<AsyncText ms={100} text="Sibling" fakeRenderDuration={1} />
</Suspense>
</>,
);
// TODO: This is here only to shift us into the next JND bucket. A
// consequence of AsyncText relying on the same timer queue as React's
// internal Suspense timer. We should decouple our AsyncText helpers
// from timers.
Scheduler.unstable_advanceTime(200);
await waitForAll([
'App',
'Suspending',
'Suspend! [Loaded]',
'Fallback',
'Suspend! [Sibling]',
]);
expect(ReactNoop).toMatchRenderedOutput('Loading...');
expect(onRender).toHaveBeenCalledTimes(3);
// Resolve the pending promise.
await act(async () => {
jest.advanceTimersByTime(100);
assertLog([
'Promise resolved [Loaded]',
'Promise resolved [Sibling]',
]);
await waitForAll(['Suspending', 'Loaded', 'New', 'Sibling']);
});
expect(onRender).toHaveBeenCalledTimes(4);
// When the suspending data is resolved and our final UI is rendered,
// both times should include the 6ms rendering Text,
// the 2ms rendering Suspending, and the 1ms rendering AsyncText.
expect(onRender.mock.calls[3][2]).toBe(9);
expect(onRender.mock.calls[3][3]).toBe(9);
});
});
});
});