let React;
let Fragment;
let ReactNoop;
let Scheduler;
let act;
let waitFor;
let waitForAll;
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;
assertLog = InternalTestUtils.assertLog;
getCacheForType = React.unstable_getCacheForType;
caches = [];
seededCache = null;
});function createTextCache() {
if (seededCache !== null) {
// Trick to seed a cache before it exists.
// TODO: Need a built-in API to seed data before the initial render (i.e.
// not a refresh because nothing has mounted yet).
const cache = seededCache;
seededCache = null;
return cache;
}const data = new Map();
const version = caches.length + 1;
const cache = {
version,
data,
resolve(text) {
const record = data.get(text);
if (record === undefined) {
const newRecord = {
status: 'resolved',
value: text,
};data.set(text, newRecord);
} else if (record.status === 'pending') {
const thenable = record.value;
record.status = 'resolved';
record.value = text;
thenable.pings.forEach(t => t());
}},reject(text, error) {
const record = data.get(text);
if (record === undefined) {
const newRecord = {
status: 'rejected',
value: error,
};data.set(text, newRecord);
} else if (record.status === 'pending') {
const thenable = record.value;
record.status = 'rejected';
record.value = error;
thenable.pings.forEach(t => t());
}},};caches.push(cache);
return cache;
}function readText(text) {
const textCache = getCacheForType(createTextCache);
const record = textCache.data.get(text);
if (record !== undefined) {
switch (record.status) {
case 'pending':
Scheduler.log(`Suspend! [${text}]`);
throw record.value;
case 'rejected':
Scheduler.log(`Error! [${text}]`);
throw record.value;
case 'resolved':
return textCache.version;
}} else {
Scheduler.log(`Suspend! [${text}]`);
const thenable = {
pings: [],then(resolve) {
if (newRecord.status === 'pending') {
thenable.pings.push(resolve);
} else {
Promise.resolve().then(() => resolve(newRecord.value));
}},};const newRecord = {
status: 'pending',
value: thenable,
};textCache.data.set(text, newRecord);
throw thenable;
}}function Text({text}) {
Scheduler.log(text);
return <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 {
// Resolve the most recently created cache. An older cache can by
// resolved with `caches[index].resolve(text)`.
caches[caches.length - 1].resolve(text);
}}const resolveText = resolveMostRecentTextCache;
function rejectMostRecentTextCache(text, error) {
if (caches.length === 0) {
throw Error('Cache does not exist.');
} else {
// Resolve the most recently created cache. An older cache can by
// resolved with `caches[index].reject(text, error)`.
caches[caches.length - 1].reject(text, error);
}}const rejectText = rejectMostRecentTextCache;
function advanceTimers(ms) {
// Note: This advances Jest's virtual time but not React's. Use
// ReactNoop.expire for that.
if (typeof ms !== 'number') {
throw new Error('Must specify ms');
}jest.advanceTimersByTime(ms);
// Wait until the end of the current tick
// We cannot use a timer since we're faking them
return Promise.resolve().then(() => {});
}// Note: This is based on a similar component we use in www. We can delete
// once the extra div wrapper is no longer necessary.
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>
);
}// @gate enableLegacyCache
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" ms={100} />
<Text text="B" />
</Bar>
</Suspense>
<Text text="C" />
<Text text="D" />
</>
);}React.startTransition(() => {
ReactNoop.render(<Foo />);
});await waitFor([
'Foo','Bar',// A suspends'Suspend! [A]',
// We immediately unwind and switch to a fallback without// rendering siblings.
'Loading...',
'C',// Yield before rendering D]);expect(ReactNoop).toMatchRenderedOutput(null);
// Flush the promise completely
await act(async () => {
await resolveText('A');
// Even though the promise has resolved, we should now flush
// and commit the in progress render instead of restarting.
await waitForPaint(['D']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Loading..." />
<span prop="C" />
<span prop="D" />
</>,
);// Next, we'll flush the complete content.
await waitForAll(['Bar', 'A', 'B']);
});expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" />
<span prop="B" />
<span prop="C" />
<span prop="D" />
</>,
);});// @gate enableLegacyCache
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>
);}// Render empty shell.
ReactNoop.render(<Foo />);
await waitForAll(['Foo']);
// The update will suspend.
React.startTransition(() => {
ReactNoop.render(<Foo renderBar={true} />);
});await waitForAll([
'Foo',
'Bar',
// A suspends
'Suspend! [A]',
// We immediately unwind and switch to a fallback without
// rendering siblings.
'Loading...',
]);expect(ReactNoop).toMatchRenderedOutput(null);
// Resolve the data
await resolveText('A');
// Renders successfully
await waitForAll(['Foo', 'Bar', 'A', 'B']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" />
<span prop="B" />
</>,
);});// @gate enableLegacyCache
it('suspends siblings and later recovers each independently', async () => {
// Render two sibling Suspense components
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...',
]);expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Loading A..." />
<span prop="Loading B..." />
</>,
);// Resolve first Suspense's promise so that it switches switches back to the
// normal view. The second Suspense should still show the placeholder.
await act(() => resolveText('A'));
assertLog(['A']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" />
<span prop="Loading B..." />
</>,
);// Resolve the second Suspense's promise so that it switches back to the
// normal view.
await act(() => resolveText('B'));
assertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" />
<span prop="B" />
</>,
);});// @gate enableLegacyCache
it('when something suspends, unwinds immediately without rendering siblings', async () => {
// A shell is needed. The update cause it to suspend.
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>,
);});// B suspends. Render a fallback
await waitForAll(['A', 'Suspend! [B]', 'Loading...']);
// Did not commit yet.
expect(ReactNoop).toMatchRenderedOutput(null);
// Wait for data to resolve
await resolveText('B');
await waitForAll(['A', 'B', 'C', 'D']);
// Renders successfully
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" />
<span prop="B" />
<span prop="C" />
<span prop="D" />
</>,
);});// Second condition is redundant but guarantees that the test runs in prod.
// TODO: Delete this feature flag.
// @gate !replayFailedUnitOfWorkWithInvokeGuardedCallback || !__DEV__
// @gate enableLegacyCache
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" ms={1000} />
</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]',
// React retries one more time
'Error! [Result]',
// Errored again on retry. Now handle it.
'Caught error: Failed to load: Result',
]);expect(ReactNoop).toMatchRenderedOutput(
<span prop="Caught error: Failed to load: Result" />,
);});// Second condition is redundant but guarantees that the test runs in prod.
// TODO: Delete this feature flag.
// @gate !replayFailedUnitOfWorkWithInvokeGuardedCallback || !__DEV__
// @gate enableLegacyCache
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...']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
await act(() => rejectText('Result', new Error('Failed to load: Result')));
assertLog([
'Error! [Result]',
// React retries one more time
'Error! [Result]',
// Errored again on retry. Now handle it.
'Caught error: Failed to load: Result',
]);expect(ReactNoop).toMatchRenderedOutput(
<span prop="Caught error: Failed to load: Result" />,
);});// @gate enableLegacyCache
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>
</>
);
}// Initial mount
await act(() => ReactNoop.render(<App />));
assertLog(['A', 'Suspend! [1]', 'Loading...']);
await act(() => resolveText('1'));
assertLog(['1']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" />
<span prop="1" />
</>,
);// Update the low-pri text
await act(() => startTransition(() => setLowPri('2')));
// Suspends
assertLog(['Suspend! [2]', 'Loading...']);
// While we're still waiting for the low-pri update to complete, update the
// high-pri text at high priority.
ReactNoop.flushSync(() => {
setHighPri('B');
});assertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="B" />
<span prop="1" />
</>,
);// Unblock the low-pri text and finish. Nothing in the UI changes because
// the update was overriden
await act(() => resolveText('2'));
assertLog(['2']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="B" />
<span prop="2" />
</>,
);});// @gate enableLegacyCache
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]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(null);
await resolveText('A');await waitForAll(['A', 'B']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" /><span prop="B" /></>,);});// @gate enableLegacyCache
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 ms={2000} text="Async" />
</Suspense>
);}// Schedule a default pri update and a low pri update, without rendering in between.
// Default pri
ReactNoop.render(<App />);
// Low pri
React.startTransition(() => {
ReactNoop.render(<App hide={true} />);
});await waitForAll([
// The first update suspends
'Suspend! [Async]',
// but we have another pending update that we can work on
'(empty)',
]);expect(ReactNoop).toMatchRenderedOutput(<span prop="(empty)" />);
});// Note: This test was written to test a heuristic used in the expiration
// times model. Might not make sense in the new model.
// TODO: This test doesn't over what it was originally designed to test.
// Either rewrite or delete.
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() {// React has a heuristic to batch all updates that occur within the same
// event. This is a trick to circumvent that heuristic.
ReactNoop.flushSync(() => {
ReactNoop.renderToRootWithID(null, 'other-root');
});}// Mount the Suspense boundary without suspending, so that the subsequent
// updates suspend with a delay.
await act(() => {
root.render(<App step={0} shouldSuspend={false} />);
});await advanceTimers(1000);
assertLog(['Sibling', 'Step 0']);
// Schedule an update at several distinct expiration times
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']);
});// @gate enableLegacyCache
it('switches to an inner fallback after suspending for a while', async () => {
// Advance the virtual time so that we're closer to the edge of a bucket.
ReactNoop.expire(200);
ReactNoop.render(
<Fragment>
<Text text="Sync" />
<Suspense fallback={<Text text="Loading outer..." />}>
<AsyncText text="Outer content" ms={300} />
<Suspense fallback={<Text text="Loading inner..." />}>
<AsyncText text="Inner content" ms={1000} />
</Suspense>
</Suspense>
</Fragment>,
);await waitForAll([
'Sync',// The async content suspends'Suspend! [Outer content]',
'Loading outer...',
]);// The outer loading state finishes immediately.
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Sync" />
<span prop="Loading outer..." />
</>,
);// Resolve the outer promise.
await resolveText('Outer content');
await waitForAll([
'Outer content',
'Suspend! [Inner content]',
'Loading inner...',
]);// Don't commit the inner placeholder yet.
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Sync" />
<span prop="Loading outer..." />
</>,
);// Expire the inner timeout.
ReactNoop.expire(500);
await advanceTimers(500);
// Now that 750ms have elapsed since the outer placeholder timed out,
// we can timeout the inner placeholder.
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Sync" />
<span prop="Outer content" />
<span prop="Loading inner..." />
</>,
);// Finally, flush the inner promise. We should see the complete screen.
await act(() => resolveText('Inner content'));
assertLog(['Inner content']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Sync" />
<span prop="Outer content" />
<span prop="Inner content" />
</>,
);});// @gate enableLegacyCache
it('renders an Suspense boundary synchronously', async () => {
spyOnDev(console, 'error');
// Synchronously render a tree that suspends
ReactNoop.flushSync(() =>
ReactNoop.render(
<Fragment>
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Async" />
</Suspense>
<Text text="Sync" />
</Fragment>,
),);assertLog([
// The async child suspends'Suspend! [Async]',
// We immediately render the fallback UI'Loading...',
// Continue on the sibling'Sync',]);// The tree commits synchronously
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Loading..." />
<span prop="Sync" />
</>,
);// Once the promise resolves, we render the suspended view
await act(() => resolveText('Async'));
assertLog(['Async']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Async" />
<span prop="Sync" />
</>,
);});// @gate enableLegacyCache
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)...',
]);// The tree commits synchronously
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading (outer)..." />);
});// @gate enableLegacyCache
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');// Because we're already showing a fallback, interrupt the current render
// and restart immediately.
await waitForAll(['Async', 'Sibling']);
expect(root).toMatchRenderedOutput(
<><span prop="Async" />
<span prop="Sibling" />
</>,
);});// @gate enableLegacyCache
it('in concurrent mode, does not error when an update suspends without a Suspense boundary during a sync update', () => {
// NOTE: We may change this to be a warning in the future.
expect(() => {
ReactNoop.flushSync(() => {
ReactNoop.render(<AsyncText text="Async" />);
});}).not.toThrow();
});// @gate enableLegacyCache
it('in legacy mode, errors when an update suspends without a Suspense boundary during a sync update', () => {
const root = ReactNoop.createLegacyRoot();
expect(() => root.render(<AsyncText text="Async" />)).toThrow(
'A component suspended while responding to synchronous input.',
);});// @gate enableLegacyCache
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...']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
await act(() => {resolveText('A');resolveText('B');});assertLog(['A', 'B']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" /><span prop="B" /></>,);});// @gate enableLegacyCache
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);
// Resolve the promise
await resolveText('Async');
// We can now resume rendering
await waitForAll(['Async']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Async" />);
});// @gate enableLegacyCache
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>
);}// First mount without suspending. This ensures we already have content
// showing so that subsequent updates will suspend.
ReactNoop.render(<App text="S" />);
await waitForAll(['S']);
// Schedule an update, and suspend for up to 5 seconds.
React.startTransition(() => ReactNoop.render(<App text="A" />));
// The update should suspend.
await waitForAll(['Suspend! [A]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="S" />);
// Advance time until right before it expires.
await advanceTimers(4999);
ReactNoop.expire(4999);
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="S" />);
// Schedule another low priority update.
React.startTransition(() => ReactNoop.render(<App text="B" />));
// This update should also suspend.
await waitForAll(['Suspend! [B]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="S" />);
// Schedule a regular update. Its expiration time will fall between
// the expiration times of the previous two updates.
ReactNoop.render(<App text="C" />);
await waitForAll(['C']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="C" />);
// Flush the remaining work.
await resolveText('A');
await resolveText('B');
// Nothing else to render.
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="C" />);
});// @gate enableLegacyCache
it('a suspended update that expires', async () => {
// Regression test. This test used to fall into an infinite loop.
function ExpensiveText({text}) {
// This causes the update to expire.
Scheduler.unstable_advanceTime(10000);
// Then something suspends.
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]']);
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', () => {// @gate enableLegacyCacheit('times out immediately', async () => {function App() {return (<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Result" /></Suspense>);}// Times out immediately, ignoring the specified threshold.
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" />);
});// @gate enableLegacyCacheit('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>);}// Initial mount.
await seedNextTextCache('Step: 1');ReactNoop.renderLegacySyncRoot(<App />);
assertLog(['Step: 1', 'Sibling']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Step: 1" /><span prop="Sibling" /></>,);// Update.
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" /></>,);});// @gate enableLegacyCacheit('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]',
// This should be a mount, not an update.
'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" /></>,);});// @gate enableLegacyCacheit('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" />);
});// @gate enableLegacyCacheit('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...',
// Re-render due to lifecycle update'Loading...',
]);expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
await act(() => {resolveText('Hi');});assertLog(['Hi']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Hi" />);
});if (global.__PERSISTENT__) {
// @gate enableLegacyCacheit('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...',
// The child should have already been hidden<><span hidden={true} /><span prop="Loading..." />
</>,]);await act(() => {resolveText('Hi');});assertLog(['Hi']);
});} else {// @gate enableLegacyCacheit('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...',
// The child should have already been hidden'Child is hidden: true',]);await act(() => {resolveText('Hi');});assertLog(['Hi']);
});}// @gate enableLegacyCacheit('handles errors in the return path of a component that suspends', async () => {// Covers an edge case where an error is thrown inside the complete phase// of a component that is in the return path of a component that suspends.
// The second error should also be handled (i.e. able to be captured by
// an error boundary.
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');
// Suspend the child. This puts it into an inconsistent state.
await act(() => {setShouldSuspend(true);});expect(root).toMatchRenderedOutput('Loading...');
// Unmount everythingawait act(() => {root.render(null);
});assertLog(['Unmount']);
});});// @gate enableLegacyCacheit('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]',
// B's lifecycle should not fire because it suspended// 'Mount [B]',
'Mount [C]',
'Mount [Loading...]',
'Commit root',]);expect(ReactNoop).toMatchRenderedOutput(
<><span hidden={true} prop="A" /><span hidden={true} prop="C" /><span prop="Loading..." />
</>,);});// @gate enableLegacyCacheit('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]',
// B's effect should not fire because it suspended// 'Layout Effect [B]',
'Layout Effect [C]',
'Layout Effect [Loading...]',
'Commit root',]);// Flush passive effects.
await waitForAll([
'Effect [A]',
// B's effect should not fire because it suspended// 'Effect [B]',
'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]',
]);// UpdateReactNoop.renderLegacySyncRoot(<App text="B2" />, () =>
Scheduler.log('Commit root'),
);assertLog([
'A','Suspend! [B2]',
'C','Loading...',
// B2's effect should not fire because it suspended// 'Layout Effect [B2]',
'Layout Effect [Loading...]',
'Commit root',]);// Flush passive effects.
await waitForAll([
// B2's effect should not fire because it suspended// 'Effect [B2]',
'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]',
]);});// @gate enableLegacyCacheit('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 />);
// Start renderingawait waitForAll([
'Foo',// A suspends'Suspend! [A]',
'Loading...',
]);expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
// Wait a long time.
Scheduler.unstable_advanceTime(5000);
await advanceTimers(5000);// Retry with the new content.
await resolveText('A');await waitForAll([
'A',// B suspends'Suspend! [B]',
'Loading more...',
]);// Because we've already been waiting for so long we've exceeded// our threshold and we show the next level immediately.
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" /><span prop="Loading more..." />
</>,);// Flush the last promise completelyawait act(() => resolveText('B'));// Renders successfullyassertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" /><span prop="B" /></>,);});// @gate enableLegacyCacheit('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 />);
// Start renderingawait waitForAll([
'Foo',// A suspends'Suspend! [A]',
'Loading...',
]);expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
await act(async () => {await resolveText('A');// Retry with the new content.
await waitForAll([
'A',// B suspends'Suspend! [B]',
'Loading more...',
]);// Because we've already been waiting for so long we can// wait a bit longer. Still nothing...
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
// Before we commit another Promise resolves.
// We're still showing the first loading state.
await resolveText('B');expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
// Restart and render the complete content.
await waitForAll(['A', 'B']);
if (gate(flags => flags.alwaysThrottleRetries)) {
// Correct behavior://// The tree will finish but we won't commit the result yet because the fallback appeared recently.
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
} else {// Old behavior, gated until this rolls out at Meta://// TODO: Because this render was the result of a retry, and a fallback// was shown recently, we should suspend and remain on the fallback for// little bit longer. We currently only do this if there's still
// remaining fallbacks in the tree, but we should do it for all retries.
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" /><span prop="B" /></>,);}});assertLog([]);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" /><span prop="B" /></>,);});// @gate enableLegacyCacheit('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 />);
// Start renderingawait waitForAll([
'Foo','Suspend! [A]',
'Loading A...',
'Suspend! [B]',
'Loading B...',
]);expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Loading A..." />
<span prop="Loading B..." />
</>,);// Resolve only A. B will still be loading.
await act(async () => {await resolveText('A');// If we didn't advance the time here, A would not commit; it would// be throttled because the fallback would have appeared too recently.
Scheduler.unstable_advanceTime(10000);
jest.advanceTimersByTime(10000);
await waitForPaint(['A']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" /><span prop="Loading B..." />
</>,);});// Advance by a small amount of time. For testing purposes, this is meant
// to be just under the throttling interval. It's a heurstic, though, so
// if we adjust the heuristic we might have to update this test, too.
Scheduler.unstable_advanceTime(200);
jest.advanceTimersByTime(200);
// Now resolve B.
await act(async () => {await resolveText('B');await waitForPaint(['B']);
if (gate(flags => flags.alwaysThrottleRetries)) {
// B should not commit yet. Even though it's been a long time since its
// fallback was shown, it hasn't been long since A appeared. So B's
// appearance is throttled to reduce jank.
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" /><span prop="Loading B..." />
</>,);// Advance time a little bit more. Now it commits because enough time
// has passed.
Scheduler.unstable_advanceTime(100);
jest.advanceTimersByTime(100);
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" /><span prop="B" /></>,);} else {// Old behavior, gated until this rolls out at Meta://// B appears immediately, without being throttled.
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" /><span prop="B" /></>,);}});});// TODO: flip to "warns" when this is implemented again.
// @gate enableLegacyCacheit('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 />);
});// TODO: assert toErrorDev() when the warning is implemented again.
await act(() => {ReactNoop.flushSync(() => _setShow(true));
});});// TODO: flip to "warns" when this is implemented again.
// @gate enableLegacyCacheit('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 />);
});// TODO: assert toErrorDev() when the warning is implemented again.
await act(() => {ReactNoop.flushSync(() => show());
});});// @gate enableLegacyCacheit('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]']);
expect(ReactNoop).toMatchRenderedOutput('Loading...');
await act(() => {ReactNoop.flushSync(() => showB());
});assertLog(['Suspend! [A]']);
});// TODO: flip to "warns" when this is implemented again.
// @gate enableLegacyCacheit('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 />);
});// TODO: assert toErrorDev() when the warning is implemented again.
await act(() => {ReactNoop.flushSync(() => _setShow(true));
});},);// @gate enableLegacyCacheit('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 />);
});// also make sure lowpriority is okayawait act(() => show(true));assertLog(['Suspend! [A]']);
await resolveText('A');expect(ReactNoop).toMatchRenderedOutput('Loading...');
});// @gate enableLegacyCacheit('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 />);
});// also make sure lowpriority is okayawait act(() => _setShow(true));assertLog(['Suspend! [A]']);
await resolveText('A');expect(ReactNoop).toMatchRenderedOutput('Loading...');
});// @gate enableLegacyCache && enableSuspenseAvoidThisFallbackit('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..." />}>
<Suspenseunstable_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...']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Initial load..." />);
// Eventually we resolve and show the data.
await act(() => resolveText('A'));assertLog(['A', 'B']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" /><span prop="B" /></>,);// Update to show CReactNoop.render(<Foo showC={true} />);
await waitForAll(['Foo', 'A', 'Suspend! [C]', 'Updating...', 'B']);
// Flush to skip suspended time.
Scheduler.unstable_advanceTime(600);
await advanceTimers(600);// Since the optional suspense boundary is already showing its content,// we have to use the inner fallback instead.
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" hidden={true} /><span prop="Updating..." />
<span prop="B" /></>,);// Later we load the data.
await act(() => resolveText('C'));assertLog(['A', 'C']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" /><span prop="C" /><span prop="B" /></>,);});// @gate enableLegacyCacheit('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',// null]);expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
// Eventually we resolve and show the data.
await act(() => resolveText('A'));assertLog(['A']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" /><span prop="B" /></>,);// Update to show CReactNoop.render(<Foo showC={true} />);
await waitForAll([
'Foo','A','Suspend! [C]',
// null'B',]);// Flush to skip suspended time.
Scheduler.unstable_advanceTime(600);
await advanceTimers(600);expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" hidden={true} /><span prop="B" /></>,);// Later we load the data.
await act(() => resolveText('C'));assertLog(['A', 'C']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" /><span prop="C" /><span prop="B" /></>,);});// @gate enableLegacyCacheit('favors showing the inner fallback for nested top level avoided fallback', async () => {function Foo({showB}) {Scheduler.log('Foo');
return (<Suspenseunstable_avoidThisFallback={true}fallback={<Text text="Loading A..." />}>
<Text text="A" /><Suspenseunstable_avoidThisFallback={true}fallback={<Text text="Loading B..." />}>
<AsyncText text="B" /></Suspense></Suspense>);}ReactNoop.render(<Foo />);
await waitForAll(['Foo', 'A', 'Suspend! [B]', 'Loading B...']);
// Flush to skip suspended time.
Scheduler.unstable_advanceTime(600);
await advanceTimers(600);expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" /><span prop="Loading B..." />
</>,);});// @gate enableLegacyCache && enableSuspenseAvoidThisFallbackit('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..." />}>
<Suspenseunstable_avoidThisFallback={true}fallback={<Text text="Loading A..." />}>
<Text text="A" />{showB ? (
<Suspenseunstable_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" />);
if (gate(flags => flags.forceConcurrentByDefaultForTesting)) {
ReactNoop.render(<Foo showB={true} />);
} else {React.startTransition(() => {
ReactNoop.render(<Foo showB={true} />);
});}await waitForAll(['Foo', 'A', 'Suspend! [B]', 'Loading B...']);
if (gate(flags => flags.forceConcurrentByDefaultForTesting)) {
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="A" /><span prop="Loading B..." />
</>,);} else {// Transitions never fall back.
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
}});// @gate enableLegacyCacheit('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]',
// Null]);// Still suspended.
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
// Flush to skip suspended time.
Scheduler.unstable_advanceTime(600);
await advanceTimers(600);expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
});describe('startTransition', () => {// @gate enableLegacyCacheit('top level render', async () => {function App({page}) {return (<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={page} /></Suspense>);}// Initial render.
React.startTransition(() => ReactNoop.render(<App page="A" />));
await waitForAll(['Suspend! [A]', 'Loading...']);
// Only a short time is needed to unsuspend the initial loading state.
Scheduler.unstable_advanceTime(400);
await advanceTimers(400);expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
// Later we load the data.
await act(() => resolveText('A'));assertLog(['A']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
// Start transition.
React.startTransition(() => ReactNoop.render(<App page="B" />));
await waitForAll(['Suspend! [B]', 'Loading...']);
Scheduler.unstable_advanceTime(100000);
await advanceTimers(100000);// Even after lots of time has passed, we have still not yet flushed the// loading state.
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
// Later we load the data.
await act(() => resolveText('B'));assertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
});// @gate enableLegacyCacheit('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([]);
// Initial render.
await act(async () => {React.startTransition(() => transitionToPage('A'));
await waitForAll(['Suspend! [A]', 'Loading...']);
// Only a short time is needed to unsuspend the initial loading state.
Scheduler.unstable_advanceTime(400);
await advanceTimers(400);expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
});// Later we load the data.
await act(() => resolveText('A'));assertLog(['A']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
// Start transition.
await act(async () => {React.startTransition(() => transitionToPage('B'));
await waitForAll(['Suspend! [B]', 'Loading...']);
Scheduler.unstable_advanceTime(100000);
await advanceTimers(100000);// Even after lots of time has passed, we have still not yet flushed the// loading state.
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
});// Later we load the data.
await act(() => resolveText('B'));assertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
});// @gate enableLegacyCacheit('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([]);
// Initial render.
await act(async () => {React.startTransition(() => transitionToPage('A'));
await waitForAll(['Suspend! [A]', 'Loading...']);
// Only a short time is needed to unsuspend the initial loading state.
Scheduler.unstable_advanceTime(400);
await advanceTimers(400);expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
});// Later we load the data.
await act(() => resolveText('A'));assertLog(['A']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
// Start transition.
await act(async () => {React.startTransition(() => transitionToPage('B'));
await waitForAll(['Suspend! [B]', 'Loading...']);
Scheduler.unstable_advanceTime(100000);
await advanceTimers(100000);// Even after lots of time has passed, we have still not yet flushed the// loading state.
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
});// Later we load the data.
await act(() => resolveText('B'));assertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
});});describe('delays transitions when using React.startTransition', () => {
// @gate enableLegacyCacheit('top level render', async () => {function App({page}) {return (<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={page} /></Suspense>);}// Initial render.
React.startTransition(() => ReactNoop.render(<App page="A" />));
await waitForAll(['Suspend! [A]', 'Loading...']);
// Only a short time is needed to unsuspend the initial loading state.
Scheduler.unstable_advanceTime(400);
await advanceTimers(400);expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
// Later we load the data.
await act(() => resolveText('A'));assertLog(['A']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
// Start transition.
React.startTransition(() => ReactNoop.render(<App page="B" />));
await waitForAll(['Suspend! [B]', 'Loading...']);
Scheduler.unstable_advanceTime(2999);
await advanceTimers(2999);// Since the timeout is infinite (or effectively infinite),// we have still not yet flushed the loading state.
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
// Later we load the data.
await act(() => resolveText('B'));assertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
// Start a long (infinite) transition.
React.startTransition(() => ReactNoop.render(<App page="C" />));
await waitForAll(['Suspend! [C]', 'Loading...']);
// Even after lots of time has passed, we have still not yet flushed the// loading state.
Scheduler.unstable_advanceTime(100000);
await advanceTimers(100000);expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
});// @gate enableLegacyCacheit('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([]);
// Initial render.
await act(async () => {React.startTransition(() => transitionToPage('A'));
await waitForAll(['Suspend! [A]', 'Loading...']);
// Only a short time is needed to unsuspend the initial loading state.
Scheduler.unstable_advanceTime(400);
await advanceTimers(400);expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
});// Later we load the data.
await act(() => resolveText('A'));assertLog(['A']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
// Start transition.
await act(async () => {React.startTransition(() => transitionToPage('B'));
await waitForAll(['Suspend! [B]', 'Loading...']);
Scheduler.unstable_advanceTime(2999);
await advanceTimers(2999);// Since the timeout is infinite (or effectively infinite),// we have still not yet flushed the loading state.
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
});// Later we load the data.
await act(() => resolveText('B'));assertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
// Start a long (infinite) transition.
await act(async () => {React.startTransition(() => transitionToPage('C'));
await waitForAll(['Suspend! [C]', 'Loading...']);
// Even after lots of time has passed, we have still not yet flushed the// loading state.
Scheduler.unstable_advanceTime(100000);
await advanceTimers(100000);expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
});});// @gate enableLegacyCacheit('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([]);
// Initial render.
await act(async () => {React.startTransition(() => transitionToPage('A'));
await waitForAll(['Suspend! [A]', 'Loading...']);
// Only a short time is needed to unsuspend the initial loading state.
Scheduler.unstable_advanceTime(400);
await advanceTimers(400);expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
});// Later we load the data.
await act(() => resolveText('A'));assertLog(['A']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
// Start transition.
await act(async () => {React.startTransition(() => transitionToPage('B'));
await waitForAll(['Suspend! [B]', 'Loading...']);
Scheduler.unstable_advanceTime(2999);
await advanceTimers(2999);// Since the timeout is infinite (or effectively infinite),// we have still not yet flushed the loading state.
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
});// Later we load the data.
await act(() => resolveText('B'));assertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
// Start a long (infinite) transition.
await act(async () => {React.startTransition(() => transitionToPage('C'));
await waitForAll(['Suspend! [C]', 'Loading...']);
// Even after lots of time has passed, we have still not yet flushed the// loading state.
Scheduler.unstable_advanceTime(100000);
await advanceTimers(100000);expect(ReactNoop).toMatchRenderedOutput(<span prop="B" />);
});});});// @gate enableLegacyCache && enableSuspenseAvoidThisFallbackit('do not show placeholder when updating an avoided boundary with startTransition', async () => {function App({page}) {return (<Suspense fallback={<Text text="Loading..." />}>
<Text text="Hi!" /><Suspensefallback={<Text text={'Loading ' + page + '...'} />}
unstable_avoidThisFallback={true}><AsyncText text={page} /></Suspense></Suspense>);}// Initial render.
ReactNoop.render(<App page="A" />);
await waitForAll(['Hi!', 'Suspend! [A]', 'Loading...']);
await act(() => resolveText('A'));assertLog(['Hi!', 'A']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Hi!" /><span prop="A" /></>,);// Start transition.
React.startTransition(() => ReactNoop.render(<App page="B" />));
await waitForAll(['Hi!', 'Suspend! [B]', 'Loading B...']);
// Suspendedexpect(ReactNoop).toMatchRenderedOutput(
<><span prop="Hi!" /><span prop="A" /></>,);Scheduler.unstable_advanceTime(1800);
await advanceTimers(1800);await waitForAll([]);
// We should still be suspended here because this loading state should be avoided.
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" /></>,);});// @gate enableLegacyCache && enableSuspenseAvoidThisFallbackit('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" />) : (<Suspensefallback={<Text text={'Loading ' + page + '...'} />}
unstable_avoidThisFallback={true}><AsyncText text={page} /></Suspense>)}</Suspense>);}// Initial render.
ReactNoop.render(<App page="A" />);
await waitForAll(['Hi!', 'A']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Hi!" /><span prop="A" /></>,);// Start transition.
React.startTransition(() => ReactNoop.render(<App page="B" />));
await waitForAll(['Hi!', 'Suspend! [B]', 'Loading B...']);
// Suspendedexpect(ReactNoop).toMatchRenderedOutput(
<><span prop="Hi!" /><span prop="A" /></>,);Scheduler.unstable_advanceTime(1800);
await advanceTimers(1800);await waitForAll([]);
// We should still be suspended here because this loading state should be avoided.
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});
// In the regression that this covers, we would neglect to reset the// current debug phase after suspending (in the catch block), so React// thinks we're still inside the render phase.
await waitFor(['Suspend!']);
// Then when this setState happens, React would incorrectly fire a warning// about updates that happen the render phase (only fired by classes).
foo.setState({suspend: false});
});assertLog([
// First setState'Foo',]);
expect(root).toMatchRenderedOutput(<span prop="Foo" />);
});// @gate enableLegacyCache && enableLegacyHiddenit('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" ms={2000} /> : null}
</Suspense></>);}// Initial render.
ReactNoop.render(<App showContent={false} />);
await waitFor(['Commit']);
expect(ReactNoop).toMatchRenderedOutput(<div hidden={true} />);
// Start transition.
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" /></>,);});// @gate enableLegacyCache && enableLegacyHiddenit('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>);}// Initial render.
ReactNoop.render(<App showContent={false} />);
await waitFor(['Commit']);
expect(ReactNoop).toMatchRenderedOutput(<div hidden={true} />);
// Partially render through the hidden content.
await waitFor(['Suspend! [A]']);
// Start transition.
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" /></>,);});// @gate enableLegacyCacheit('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 () => {// Schedule two updates that originate inside the Suspense boundary.
// The first one causes the boundary to suspend. The second one is at
// lower priority and unsuspends the tree.
ReactNoop.discreteUpdates(() => {
setText('B');});startTransition(() => {setText('C');});// Assert that neither update has happened yet. Both the high pri and
// low pri updates are in the queue.
assertLog([]);
// Resolve this before starting to render so that C doesn't suspend.
await resolveText('C');});assertLog([
// First we attempt the high pri update. It suspends.'Suspend! [B]',
'Loading...',
// Then we attempt the low pri update, which finishes successfully.
'C',]);expect(root).toMatchRenderedOutput(<span prop="C" />);
},);// @gate enableLegacyCacheit('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 () => {// Schedule two updates that originate inside the Suspense boundary.
// The first one causes the boundary to suspend. The second one is at
// lower priority and unsuspends it by hiding the async component.
setText('B');await resolveText('C');ReactNoop.idleUpdates(() => {
setText('C');});// First we attempt the high pri update. It suspends.
await waitForPaint(['Suspend! [B]', 'Loading...']);
expect(root).toMatchRenderedOutput(
<><span hidden={true} prop="A" /><span prop="Loading..." />
</>,);// Now flush the remaining work. The Idle update successfully finishes.
await waitForAll(['C']);
expect(root).toMatchRenderedOutput(<span prop="C" />);
});},);// @gate enableLegacyCacheit('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} />;}// Resolve the initial treeawait seedNextTextCache('A');await act(() => {root.render(<App />);
});assertLog(['A']);
expect(root).toMatchRenderedOutput(<span prop="A" />);
await act(async () => {// Schedule an update inside the Suspense boundary that suspends.
setAppText('B');await waitForAll(['Suspend! [B]', 'Loading...']);
});expect(root).toMatchRenderedOutput(
<><span hidden={true} prop="A" /><span prop="Loading..." />
</>,);// Schedule a default pri update on the boundary, and a lower pri update// on the fallback. We're testing to make sure the fallback can still
// update even though the primary tree is suspended.
await act(() => {setAppText('C');React.startTransition(() => {
setFallbackText('Still loading...');
});});assertLog([
// First try to render the high pri update. Still suspended.'Suspend! [C]',
'Loading...',
// In the expiration times model, once the high pri update suspends,// we can't be sure if there's additional work at a lower priority// that might unblock the tree. We do know that there's a lower
// priority update *somewhere* in the entire root, though (the update
// to the fallback). So we try rendering one more time, just in case.
// TODO: We shouldn't need to do this with lanes, because we always// know exactly which lanes have pending work in each tree.
'Suspend! [C]',
// Then complete the update to the fallback.
'Still loading...',
]);expect(root).toMatchRenderedOutput(
<><span hidden={true} prop="A" /><span prop="Still loading..." />
</>,);},);// @gate enableLegacyCacheit('regression: primary fragment fiber is not always part of setState ' +
'return path',async () => {// Reproduces a bug where updates inside a suspended tree are dropped// because the fragment fiber we insert to wrap the hidden children is not// part of the return path, so it doesn't get marked during setState.
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} />;}// Mount an initial tree. Resolve A so that it doesn't suspend.
await seedNextTextCache('A');await act(() => {root.render(<Parent />);
});assertLog(['A']);
// At this point, the setState return path follows current fiber.
expect(root).toMatchRenderedOutput(<span prop="A" />);
// Schedule another update. This will "flip" the alternate pairs.
await resolveText('B');await act(() => {setText('B');});assertLog(['B']);
// Now the setState return path follows the *alternate* fiber.
expect(root).toMatchRenderedOutput(<span prop="B" />);
// Schedule another update. This time, we'll suspend.
await act(() => {setText('C');});assertLog(['Suspend! [C]', 'Loading...']);
// Commit. This will insert a fragment fiber to wrap around the component
// that triggered the update.
await act(async () => {await advanceTimers(250);});// The fragment fiber is part of the current tree, but the setState return// path still follows the alternate path. That means the fragment fiber is
// not part of the return path.
expect(root).toMatchRenderedOutput(
<><span hidden={true} prop="B" /><span prop="Loading..." />
</>,);// Update again. This should unsuspend the tree.
await resolveText('D');await act(() => {setText('D');});// Even though the fragment fiber is not part of the return path, we should// be able to finish rendering.
assertLog(['D']);
expect(root).toMatchRenderedOutput(<span prop="D" />);
},);// @gate enableLegacyCacheit('regression: primary fragment fiber is not always part of setState ' +
'return path (another case)',async () => {// Reproduces a bug where updates inside a suspended tree are dropped// because the fragment fiber we insert to wrap the hidden children is not// part of the return path, so it doesn't get marked during setState.
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} />;}// Mount an initial tree. Resolve A so that it doesn't suspend.
await seedNextTextCache('A');await act(() => {root.render(<Parent />);
});assertLog(['A']);
// At this point, the setState return path follows current fiber.
expect(root).toMatchRenderedOutput(<span prop="A" />);
// Schedule another update. This will "flip" the alternate pairs.
await resolveText('B');await act(() => {setText('B');});assertLog(['B']);
// Now the setState return path follows the *alternate* fiber.
expect(root).toMatchRenderedOutput(<span prop="B" />);
// Schedule another update. This time, we'll suspend.
await act(() => {setText('C');});assertLog(['Suspend! [C]', 'Loading...']);
// Commit. This will insert a fragment fiber to wrap around the component
// that triggered the update.
await act(async () => {await advanceTimers(250);});// The fragment fiber is part of the current tree, but the setState return// path still follows the alternate path. That means the fragment fiber is
// not part of the return path.
expect(root).toMatchRenderedOutput(
<><span hidden={true} prop="B" /><span prop="Loading..." />
</>,);await act(async () => {// Schedule a normal pri update. This will suspend again.
setText('D');// And another update at lower priority. This will unblock.
await resolveText('E');ReactNoop.idleUpdates(() => {
setText('E');});});// Even though the fragment fiber is not part of the return path, we should// be able to finish rendering.
assertLog(['Suspend! [D]', 'E']);
expect(root).toMatchRenderedOutput(<span prop="E" />);
},);// @gate enableLegacyCacheit('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;// This will log if the component commits in an inconsistent stateuseEffect(() => {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} />
</>);}// These always update simultaneously. They must be consistent.
function setText(text) {setOuterText(text);setInnerText(text);}// Mount an initial tree. Resolve A so that it doesn't suspend.
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" /></>,);// Update. This causes the inner component to suspend.
await act(() => {setText('B');});assertLog([
'Outer text: B','Outer step: 0','Suspend! [Inner text: B]',
'Loading...',
]);// Commit the placeholderawait 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..." />
</>,);// Schedule a high pri update on the parent.
await act(() => {ReactNoop.discreteUpdates(() => {
root.render(<Parent step={1} />);
});});// Only the outer part can update. The inner part should still show a
// fallback because we haven't finished loading B yet. Otherwise, the
// inner text would be inconsistent with the outer text.
assertLog([
'Outer text: B','Outer step: 1','Suspend! [Inner text: B]',
'Loading...',
]);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..." />
</>,);// Now finish resolving the inner textawait 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" /></>,);},);// @gate enableLegacyCacheit('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;// This will log if the component commits in an inconsistent stateuseEffect(() => {if (text === outerText) {Scheduler.log('Commit Child');
} else {Scheduler.log('FIXME: Texts are inconsistent (tearing)');
}}, [text, outerText]);
return (<><AsyncText text={'Inner: ' + text + step} />
</>);}// These always update simultaneously. They must be consistent.
function setText(text) {setOuterText(text);setInnerText(text);}// Mount an initial tree. Resolve A so that it doesn't suspend.
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" /></>,);// Update. This causes the inner component to suspend.
await act(() => {setText('B');});assertLog(['Outer: B0', 'Suspend! [Inner: B0]', 'Loading...']);
// Commit the placeholderawait advanceTimers(250);expect(root).toMatchRenderedOutput(
<><span prop="Outer: B0" /><span hidden={true} prop="Inner: A0" /><span prop="Loading..." />
</>,);// Schedule a high pri update on the parent. This will unblock the content.
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" /></>,);});// @gate enableLegacyCache// @gate forceConcurrentByDefaultForTestingit('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');
// eslint-disable-next-line no-unused-varsconst [_, _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 () => {// Triggers suspense at normal prisetTextA('A1');// Triggers in an unrelated tree at a different pristartTransitionFromB(() => {// Update A again so that it doesn't suspend on A1. That way we can ping
// the A1 update without also pinging this one. This is a workaround
// because there's currently no way to render at a lower priority (B2)// without including all updates at higher priority (A1).
setTextA('A2');setTextB('B2');});await waitFor([
'B','Suspend! [A1]',
'Loading...',
'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" /></>,);});// Regression: https://github.com/facebook/react/issues/18486
// @gate enableLegacyCacheit('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) {// Render phase update was needed to repro the bug.
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="" />);
// Update to "a". That will suspend.
await act(async () => {setTextWithShortTransition('a');await waitForAll(['Pending...', '', 'Suspend! [a]', 'Loading...']);
});assertLog([]);
expect(root).toMatchRenderedOutput(
<><span prop="Pending..." />
<span prop="" /></>,);// Update to "b". That will suspend, too.
await act(async () => {setTextWithLongTransition('b');await waitForAll([
// Neither is resolved yet.'Pending...','','Suspend! [b]',
'Loading...',
]);});assertLog([]);
expect(root).toMatchRenderedOutput(
<><span prop="Pending..." />
<span prop="" /></>,);// Resolve "a". But "b" is still pending.
await act(async () => {await resolveText('a');await waitForAll(['Suspend! [b]', 'Loading...']);
expect(root).toMatchRenderedOutput(
<><span prop="Pending..." />
<span prop="" /></>,);// Resolve "b". This should remove the pending state.
await act(async () => {await resolveText('b');});assertLog(['b']);
// The bug was that the pending state got stuck forever.
expect(root).toMatchRenderedOutput(<span prop="b" />);
});});// @gate enableLegacyCacheit('regression: #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');});// Suspend the first update. This triggers an immediate fallback because
// it wasn't wrapped in startTransition.
await waitForPaint(['Suspend! [B]', 'Loading...']);
expect(root).toMatchRenderedOutput(
<><span hidden={true} prop="A" /><span prop="Loading..." />
</>,);// Once the fallback renders, proceed to the Idle update. This will
// also suspend.
await waitForAll(['Suspend! [C]']);
});// Finish loading B.
await act(async () => {setText('B');await resolveText('B');});// We did not try to render the Idle update again because there have been no// additional updates since the last time it was attempted.
assertLog(['B']);
expect(root).toMatchRenderedOutput(<span prop="B" />);
// Finish loading C.
await act(async () => {setText('C');await resolveText('C');});assertLog(['C']);
expect(root).toMatchRenderedOutput(<span prop="C" />);
});// @gate enableLegacyCacheit('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...']);
expect(root).toMatchRenderedOutput(
<><span prop="A" /><span prop="Loading..." />
</>,);await act(async () => {// Resolve the promise. This will trigger a retry.
await resolveText('Async');// Before the retry happens, schedule a new update.
setText('B');// The update should be allowed to finish before the retry is attempted.
await waitForPaint(['B']);
expect(root).toMatchRenderedOutput(
<><span prop="B" /><span prop="Loading..." />
</>,);});// Then do the retry.
assertLog(['Async']);
expect(root).toMatchRenderedOutput(
<><span prop="B" /><span prop="Async" /></>,);});// @gate enableLegacyCacheit('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...']);
expect(root).toMatchRenderedOutput(
<><span hidden={true} prop="Child" /><span prop="Loading..." />
</>,);await act(() => {root.render(null);
});assertLog(['Unmount Child']);
});// @gate enableLegacyCacheit('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']);
});// @gate enableLegacyCacheit('regression test: pinging synchronously within the render phase ' +
'does not unwind the stack',async () => {// This is a regression test that reproduces a very specific scenario that// used to cause a crash.
const thenable = {then(resolve) {resolve('hi');},status: 'pending',};function ImmediatelyPings() {if (thenable.status === 'pending') {
thenable.status = 'fulfilled';
throw thenable;}return <Text text="Hi" />;}function App({showMore}) {return (<div><Suspense fallback={<Text text="Loading..." />}>
{showMore ? (
<><AsyncText text="Async" /></>) : null}</Suspense>{showMore ? (
<Suspense><ImmediatelyPings /></Suspense>) : null}</div>);}// Initial render. This mounts a Suspense boundary, so that in the next
// update we can trigger a "suspend with delay" scenario.
const root = ReactNoop.createRoot();
await act(() => {root.render(<App showMore={false} />);
});assertLog([]);
expect(root).toMatchRenderedOutput(<div />);
// Update. This will cause two separate trees to suspend. The first tree
// will be inside an already mounted Suspense boundary, so it will trigger// a "suspend with delay". The second tree will be a new Suspense
// boundary, but the thenable that is thrown will immediately call its// ping listener.
//// Before the bug was fixed, this would lead to a `prepareFreshStack` call// that unwinds the work-in-progress stack. When that code was written, it
// was expected that pings always happen from an asynchronous task (or// microtask). But this test shows an example where that's not the case.
//// The fix was to check if we're in the render phase before calling// `prepareFreshStack`.
await act(() => {root.render(<App showMore={true} />);
});assertLog(['Suspend! [Async]', 'Loading...', 'Hi']);
expect(root).toMatchRenderedOutput(
<div><span prop="Loading..." />
<span prop="Hi" /></div>,);},);});