0% found this document useful (0 votes)
17 views

ReactSuspenseWithNoopRenderer Test

The document describes tests for React Suspense with a noop renderer. It tests that suspending components correctly switch to fallbacks and later recover after promises resolve, both for individual components and when multiple components suspend.

Uploaded by

mahoraga002
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as TXT, PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
17 views

ReactSuspenseWithNoopRenderer Test

The document describes tests for React Suspense with a noop renderer. It tests that suspending components correctly switch to fallbacks and later recover after promises resolve, both for individual components and when multiple components suspend.

Uploaded by

mahoraga002
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as TXT, PDF, TXT or read online on Scribd
You are on page 1/ 69

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 enableLegacyCache
it('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 enableLegacyCache
it('times out immediately when Suspense is in legacy mode', async () => {
class UpdatingText extends React.Component {
state = {step: 1};
render() {
return <AsyncText text={`Step: ${this.state.step}`} />;
}
}

function Spinner() {
return (
<Fragment>
<Text text="Loading (1)" />
<Text text="Loading (2)" />
<Text text="Loading (3)" />
</Fragment>
);
}

const text = React.createRef(null);


function App() {
return (
<Suspense fallback={<Spinner />}>
<UpdatingText ref={text} />
<Text text="Sibling" />
</Suspense>
);
}

// 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 enableLegacyCache
it('does not re-render siblings in loose mode', async () => {
class TextWithLifecycle extends React.Component {
componentDidMount() {
Scheduler.log(`Mount [${this.props.text}]`);
}
componentDidUpdate() {
Scheduler.log(`Update [${this.props.text}]`);
}
render() {
return <Text {...this.props} />;
}
}

class AsyncTextWithLifecycle extends React.Component {


componentDidMount() {
Scheduler.log(`Mount [${this.props.text}]`);
}
componentDidUpdate() {
Scheduler.log(`Update [${this.props.text}]`);
}
render() {
return <AsyncText {...this.props} />;
}
}

function App() {
return (
<Suspense fallback={<TextWithLifecycle text="Loading..." />}>
<TextWithLifecycle text="A" />
<AsyncTextWithLifecycle text="B" />
<TextWithLifecycle text="C" />
</Suspense>
);
}

ReactNoop.renderLegacySyncRoot(<App />, () =>


Scheduler.log('Commit root'),
);
assertLog([
'A',
'Suspend! [B]',
'C',

'Loading...',
'Mount [A]',
'Mount [B]',
'Mount [C]',
// 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 enableLegacyCache
it('suspends inside constructor', async () => {
class AsyncTextInConstructor extends React.Component {
constructor(props) {
super(props);
const text = props.text;
Scheduler.log('constructor');
readText(text);
this.state = {text};
}
componentDidMount() {
Scheduler.log('componentDidMount');
}
render() {
Scheduler.log(this.state.text);
return <span prop={this.state.text} />;
}
}

ReactNoop.renderLegacySyncRoot(
<Suspense fallback={<Text text="Loading..." />}>
<AsyncTextInConstructor text="Hi" />
</Suspense>,
);
assertLog(['constructor', 'Suspend! [Hi]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);

await act(() => {


resolveText('Hi');
});

assertLog(['constructor', 'Hi', 'componentDidMount']);


expect(ReactNoop).toMatchRenderedOutput(<span prop="Hi" />);
});

// @gate enableLegacyCache
it('does not infinite loop if fallback contains lifecycle method', async () =>
{
class Fallback extends React.Component {
state = {
name: 'foo',
};
componentDidMount() {
this.setState({
name: 'bar',
});
}
render() {
return <Text text="Loading..." />;
}
}

class Demo extends React.Component {


render() {
return (
<Suspense fallback={<Fallback />}>
<AsyncText text="Hi" />
</Suspense>
);
}
}

ReactNoop.renderLegacySyncRoot(<Demo />);

assertLog([
'Suspend! [Hi]',
'Loading...',
// 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 enableLegacyCache
it('hides/unhides suspended children before layout effects fire
(persistent)', async () => {
const {useRef, useLayoutEffect} = React;
function Parent() {
const child = useRef(null);

useLayoutEffect(() => {
Scheduler.log(ReactNoop.getPendingChildrenAsJSX());
});

return (
<span ref={child} hidden={false}>
<AsyncText text="Hi" />
</span>
);
}

function App(props) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<Parent />
</Suspense>
);
}

ReactNoop.renderLegacySyncRoot(<App middleText="B" />);

assertLog([
'Suspend! [Hi]',
'Loading...',
// The child should have already been hidden
<>
<span hidden={true} />
<span prop="Loading..." />
</>,
]);

await act(() => {


resolveText('Hi');
});
assertLog(['Hi']);
});
} else {
// @gate enableLegacyCache
it('hides/unhides suspended children before layout effects fire (mutation)',
async () => {
const {useRef, useLayoutEffect} = React;

function Parent() {
const child = useRef(null);

useLayoutEffect(() => {
Scheduler.log('Child is hidden: ' + child.current.hidden);
});

return (
<span ref={child} hidden={false}>
<AsyncText text="Hi" />
</span>
);
}
function App(props) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<Parent />
</Suspense>
);
}

ReactNoop.renderLegacySyncRoot(<App middleText="B" />);

assertLog([
'Suspend! [Hi]',
'Loading...',
// The child should have already been hidden
'Child is hidden: true',
]);

await act(() => {


resolveText('Hi');
});

assertLog(['Hi']);
});
}

// @gate enableLegacyCache
it('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 everything
await act(() => {
root.render(null);
});
assertLog(['Unmount']);
});
});

// @gate enableLegacyCache
it('does not call lifecycles of a suspended component', async () => {
class TextWithLifecycle extends React.Component {
componentDidMount() {
Scheduler.log(`Mount [${this.props.text}]`);
}
componentDidUpdate() {
Scheduler.log(`Update [${this.props.text}]`);
}
componentWillUnmount() {
Scheduler.log(`Unmount [${this.props.text}]`);
}
render() {
return <Text {...this.props} />;
}
}

class AsyncTextWithLifecycle extends React.Component {


componentDidMount() {
Scheduler.log(`Mount [${this.props.text}]`);
}
componentDidUpdate() {
Scheduler.log(`Update [${this.props.text}]`);
}
componentWillUnmount() {
Scheduler.log(`Unmount [${this.props.text}]`);
}
render() {
const text = this.props.text;
readText(text);
Scheduler.log(text);
return <span prop={text} />;
}
}

function App() {
return (
<Suspense fallback={<TextWithLifecycle text="Loading..." />}>
<TextWithLifecycle text="A" />
<AsyncTextWithLifecycle text="B" />
<TextWithLifecycle text="C" />
</Suspense>
);
}

ReactNoop.renderLegacySyncRoot(<App />, () => Scheduler.log('Commit root'));


assertLog([
'A',
'Suspend! [B]',
'C',
'Loading...',

'Mount [A]',
// 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 enableLegacyCache
it('does not call lifecycles of a suspended component (hooks)', async () => {
function TextWithLifecycle(props) {
React.useLayoutEffect(() => {
Scheduler.log(`Layout Effect [${props.text}]`);
return () => {
Scheduler.log(`Destroy Layout Effect [${props.text}]`);
};
}, [props.text]);
React.useEffect(() => {
Scheduler.log(`Effect [${props.text}]`);
return () => {
Scheduler.log(`Destroy Effect [${props.text}]`);
};
}, [props.text]);
return <Text {...props} />;
}

function AsyncTextWithLifecycle(props) {
React.useLayoutEffect(() => {
Scheduler.log(`Layout Effect [${props.text}]`);
return () => {
Scheduler.log(`Destroy Layout Effect [${props.text}]`);
};
}, [props.text]);
React.useEffect(() => {
Scheduler.log(`Effect [${props.text}]`);
return () => {
Scheduler.log(`Destroy Effect [${props.text}]`);
};
}, [props.text]);
const text = props.text;
readText(text);
Scheduler.log(text);
return <span prop={text} />;
}

function App({text}) {
return (
<Suspense fallback={<TextWithLifecycle text="Loading..." />}>
<TextWithLifecycle text="A" />
<AsyncTextWithLifecycle text={text} />
<TextWithLifecycle text="C" />
</Suspense>
);
}

ReactNoop.renderLegacySyncRoot(<App text="B" />, () =>


Scheduler.log('Commit root'),
);
assertLog([
'A',
'Suspend! [B]',
'C',
'Loading...',
'Layout Effect [A]',
// 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]',
]);

// Update
ReactNoop.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 enableLegacyCache
it('does not suspends if a fallback has been shown for a long time', async () =>
{
function Foo() {
Scheduler.log('Foo');
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="A" />
<Suspense fallback={<Text text="Loading more..." />}>
<AsyncText text="B" />
</Suspense>
</Suspense>
);
}

ReactNoop.render(<Foo />);
// Start rendering
await 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 completely


await act(() => resolveText('B'));
// Renders successfully
assertLog(['B']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="B" />
</>,
);
});

// @gate enableLegacyCache
it('throttles content from appearing if a fallback was shown recently', async ()
=> {
function Foo() {
Scheduler.log('Foo');
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="A" />
<Suspense fallback={<Text text="Loading more..." />}>
<AsyncText text="B" />
</Suspense>
</Suspense>
);
}

ReactNoop.render(<Foo />);
// Start rendering
await 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 enableLegacyCache
it('throttles content from appearing if a fallback was filled in recently', async
() => {
function Foo() {
Scheduler.log('Foo');
return (
<>
<Suspense fallback={<Text text="Loading A..." />}>
<AsyncText text="A" />
</Suspense>
<Suspense fallback={<Text text="Loading B..." />}>
<AsyncText text="B" />
</Suspense>
</>
);
}

ReactNoop.render(<Foo />);
// Start rendering
await 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 enableLegacyCache
it('does not warn when a low priority update suspends inside a high priority
update for functional components', async () => {
let _setShow;
function App() {
const [show, setShow] = React.useState(false);
_setShow = setShow;
return (
<Suspense fallback="Loading...">
{show && <AsyncText text="A" />}
</Suspense>
);
}

await act(() => {


ReactNoop.render(<App />);
});

// 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 enableLegacyCache
it('does not warn when a low priority update suspends inside a high priority
update for class components', async () => {
let show;
class App extends React.Component {
state = {show: false};

render() {
show = () => this.setState({show: true});
return (
<Suspense fallback="Loading...">
{this.state.show && <AsyncText text="A" />}
</Suspense>
);
}
}

await act(() => {


ReactNoop.render(<App />);
});

// TODO: assert toErrorDev() when the warning is implemented again.


await act(() => {
ReactNoop.flushSync(() => show());
});
});

// @gate enableLegacyCache
it('does not warn about wrong Suspense priority if no new fallbacks are shown',
async () => {
let showB;
class App extends React.Component {
state = {showB: false};

render() {
showB = () => this.setState({showB: true});
return (
<Suspense fallback="Loading...">
{<AsyncText text="A" />}
{this.state.showB && <AsyncText text="B" />}
</Suspense>
);
}
}

await act(() => {


ReactNoop.render(<App />);
});

assertLog(['Suspend! [A]']);
expect(ReactNoop).toMatchRenderedOutput('Loading...');

await act(() => {


ReactNoop.flushSync(() => showB());
});

assertLog(['Suspend! [A]']);
});

// TODO: flip to "warns" when this is implemented again.


// @gate enableLegacyCache
it(
'does not warn when component that triggered user-blocking update is between
Suspense boundary ' +
'and component that suspended',
async () => {
let _setShow;
function A() {
const [show, setShow] = React.useState(false);
_setShow = setShow;
return show && <AsyncText text="A" />;
}
function App() {
return (
<Suspense fallback="Loading...">
<A />
</Suspense>
);
}
await act(() => {
ReactNoop.render(<App />);
});

// TODO: assert toErrorDev() when the warning is implemented again.


await act(() => {
ReactNoop.flushSync(() => _setShow(true));
});
},
);

// @gate enableLegacyCache
it('normal priority updates suspending do not warn for class components', async
() => {
let show;
class App extends React.Component {
state = {show: false};

render() {
show = () => this.setState({show: true});
return (
<Suspense fallback="Loading...">
{this.state.show && <AsyncText text="A" />}
</Suspense>
);
}
}

await act(() => {


ReactNoop.render(<App />);
});

// also make sure lowpriority is okay


await act(() => show(true));

assertLog(['Suspend! [A]']);
await resolveText('A');

expect(ReactNoop).toMatchRenderedOutput('Loading...');
});

// @gate enableLegacyCache
it('normal priority updates suspending do not warn for functional components',
async () => {
let _setShow;
function App() {
const [show, setShow] = React.useState(false);
_setShow = setShow;
return (
<Suspense fallback="Loading...">
{show && <AsyncText text="A" />}
</Suspense>
);
}

await act(() => {


ReactNoop.render(<App />);
});
// also make sure lowpriority is okay
await act(() => _setShow(true));

assertLog(['Suspend! [A]']);
await resolveText('A');

expect(ReactNoop).toMatchRenderedOutput('Loading...');
});

// @gate enableLegacyCache && enableSuspenseAvoidThisFallback


it('shows the parent fallback if the inner fallback should be avoided', async ()
=> {
function Foo({showC}) {
Scheduler.log('Foo');
return (
<Suspense fallback={<Text text="Initial load..." />}>
<Suspense
unstable_avoidThisFallback={true}
fallback={<Text text="Updating..." />}>
<AsyncText text="A" />
{showC ? <AsyncText text="C" /> : null}
</Suspense>
<Text text="B" />
</Suspense>
);
}

ReactNoop.render(<Foo />);
await waitForAll(['Foo', 'Suspend! [A]', 'Initial load...']);
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 C
ReactNoop.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 enableLegacyCache
it('does not show the parent fallback if the inner fallback is not defined',
async () => {
function Foo({showC}) {
Scheduler.log('Foo');
return (
<Suspense fallback={<Text text="Initial load..." />}>
<Suspense>
<AsyncText text="A" />
{showC ? <AsyncText text="C" /> : null}
</Suspense>
<Text text="B" />
</Suspense>
);
}

ReactNoop.render(<Foo />);
await waitForAll([
'Foo',
'Suspend! [A]',
'B',
// 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 C
ReactNoop.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 enableLegacyCache
it('favors showing the inner fallback for nested top level avoided fallback',
async () => {
function Foo({showB}) {
Scheduler.log('Foo');
return (
<Suspense
unstable_avoidThisFallback={true}
fallback={<Text text="Loading A..." />}>
<Text text="A" />
<Suspense
unstable_avoidThisFallback={true}
fallback={<Text text="Loading B..." />}>
<AsyncText text="B" />
</Suspense>
</Suspense>
);
}

ReactNoop.render(<Foo />);
await waitForAll(['Foo', 'A', 'Suspend! [B]', 'Loading B...']);
// Flush to skip suspended time.
Scheduler.unstable_advanceTime(600);
await advanceTimers(600);

expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="Loading B..." />
</>,
);
});

// @gate enableLegacyCache && enableSuspenseAvoidThisFallback


it('keeps showing an avoided parent fallback if it is already showing', async ()
=> {
function Foo({showB}) {
Scheduler.log('Foo');
return (
<Suspense fallback={<Text text="Initial load..." />}>
<Suspense
unstable_avoidThisFallback={true}
fallback={<Text text="Loading A..." />}>
<Text text="A" />
{showB ? (
<Suspense
unstable_avoidThisFallback={true}
fallback={<Text text="Loading B..." />}>
<AsyncText text="B" />
</Suspense>
) : null}
</Suspense>
</Suspense>
);
}

ReactNoop.render(<Foo />);
await waitForAll(['Foo', 'A']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);

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 enableLegacyCache
it('keeps showing an undefined fallback if it is already showing', async () => {
function Foo({showB}) {
Scheduler.log('Foo');
return (
<Suspense fallback={<Text text="Initial load..." />}>
<Suspense fallback={undefined}>
<Text text="A" />
{showB ? (
<Suspense fallback={undefined}>
<AsyncText text="B" />
</Suspense>
) : null}
</Suspense>
</Suspense>
);
}
ReactNoop.render(<Foo />);
await waitForAll(['Foo', 'A']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);

React.startTransition(() => {
ReactNoop.render(<Foo showB={true} />);
});

await waitForAll([
'Foo',
'A',
'Suspend! [B]',
// 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 enableLegacyCache
it('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 enableLegacyCache
it('hooks', async () => {
let transitionToPage;
function App() {
const [page, setPage] = React.useState('none');
transitionToPage = setPage;
if (page === 'none') {
return null;
}
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={page} />
</Suspense>
);
}

ReactNoop.render(<App />);
await waitForAll([]);

// 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 enableLegacyCache
it('classes', async () => {
let transitionToPage;
class App extends React.Component {
state = {page: 'none'};
render() {
transitionToPage = page => this.setState({page});
const page = this.state.page;
if (page === 'none') {
return null;
}
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={page} />
</Suspense>
);
}
}

ReactNoop.render(<App />);
await waitForAll([]);

// 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 enableLegacyCache
it('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 enableLegacyCache
it('hooks', async () => {
let transitionToPage;
function App() {
const [page, setPage] = React.useState('none');
transitionToPage = setPage;
if (page === 'none') {
return null;
}
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={page} />
</Suspense>
);
}

ReactNoop.render(<App />);
await waitForAll([]);

// 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
it('classes', async () => {
let transitionToPage;
class App extends React.Component {
state = {page: 'none'};
render() {
transitionToPage = page => this.setState({page});
const page = this.state.page;
if (page === 'none') {
return null;
}
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={page} />
</Suspense>
);
}
}

ReactNoop.render(<App />);
await waitForAll([]);

// 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 && enableSuspenseAvoidThisFallback


it('do not show placeholder when updating an avoided boundary with
startTransition', async () => {
function App({page}) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<Text text="Hi!" />
<Suspense
fallback={<Text text={'Loading ' + page + '...'} />}
unstable_avoidThisFallback={true}>
<AsyncText text={page} />
</Suspense>
</Suspense>
);
}

// 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...']);

// Suspended
expect(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 && enableSuspenseAvoidThisFallback


it('do not show placeholder when mounting an avoided boundary with
startTransition', async () => {
function App({page}) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<Text text="Hi!" />
{page === 'A' ? (
<Text text="A" />
) : (
<Suspense
fallback={<Text text={'Loading ' + page + '...'} />}
unstable_avoidThisFallback={true}>
<AsyncText text={page} />
</Suspense>
)}
</Suspense>
);
}

// 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...']);

// Suspended
expect(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 && enableLegacyHidden


it('should not render hidden content while suspended on higher pri', async () =>
{
function Offscreen() {
Scheduler.log('Offscreen');
return 'Offscreen';
}
function App({showContent}) {
React.useLayoutEffect(() => {
Scheduler.log('Commit');
});
return (
<>
<LegacyHiddenDiv mode="hidden">
<Offscreen />
</LegacyHiddenDiv>
<Suspense fallback={<Text text="Loading..." />}>
{showContent ? <AsyncText text="A" 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 && enableLegacyHidden
it('should be able to unblock higher pri content before suspended hidden', async
() => {
function Offscreen() {
Scheduler.log('Offscreen');
return 'Offscreen';
}
function App({showContent}) {
React.useLayoutEffect(() => {
Scheduler.log('Commit');
});
return (
<Suspense fallback={<Text text="Loading..." />}>
<LegacyHiddenDiv mode="hidden">
<AsyncText text="A" />
<Offscreen />
</LegacyHiddenDiv>
{showContent ? <AsyncText text="A" /> : null}
</Suspense>
);
}

// 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 enableLegacyCache
it(
'multiple updates originating inside a Suspense boundary at different ' +
'priority levels are not dropped',
async () => {
const {useState} = React;
const root = ReactNoop.createRoot();

function Parent() {
return (
<>
<Suspense fallback={<Text text="Loading..." />}>
<Child />
</Suspense>
</>
);
}

let setText;
function Child() {
const [text, _setText] = useState('A');
setText = _setText;
return <AsyncText text={text} />;
}

await seedNextTextCache('A');
await act(() => {
root.render(<Parent />);
});
assertLog(['A']);
expect(root).toMatchRenderedOutput(<span prop="A" />);

await act(async () => {


// 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 enableLegacyCache
it(
'multiple updates originating inside a Suspense boundary at different ' +
'priority levels are not dropped, including Idle updates',
async () => {
const {useState} = React;
const root = ReactNoop.createRoot();

function Parent() {
return (
<>
<Suspense fallback={<Text text="Loading..." />}>
<Child />
</Suspense>
</>
);
}

let setText;
function Child() {
const [text, _setText] = useState('A');
setText = _setText;
return <AsyncText text={text} />;
}

await seedNextTextCache('A');
await act(() => {
root.render(<Parent />);
});
assertLog(['A']);
expect(root).toMatchRenderedOutput(<span prop="A" />);

await act(async () => {


// 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 enableLegacyCache
it(
'fallback component can update itself even after a high pri update to ' +
'the primary tree suspends',
async () => {
const {useState} = React;
const root = ReactNoop.createRoot();

let setAppText;
function App() {
const [text, _setText] = useState('A');
setAppText = _setText;
return (
<>
<Suspense fallback={<Fallback />}>
<AsyncText text={text} />
</Suspense>
</>
);
}

let setFallbackText;
function Fallback() {
const [text, _setText] = useState('Loading...');
setFallbackText = _setText;
return <Text text={text} />;
}

// Resolve the initial tree


await 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 enableLegacyCache
it(
'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 enableLegacyCache
it(
'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 enableLegacyCache
it(
'after showing fallback, should not flip back to primary content until ' +
'the update that suspended finishes',
async () => {
const {useState, useEffect} = React;
const root = ReactNoop.createRoot();

let setOuterText;
function Parent({step}) {
const [text, _setText] = useState('A');
setOuterText = _setText;
return (
<>
<Text text={'Outer text: ' + text} />
<Text text={'Outer step: ' + step} />
<Suspense fallback={<Text text="Loading..." />}>
<Child step={step} outerText={text} />
</Suspense>
</>
);
}

let setInnerText;
function Child({step, outerText}) {
const [text, _setText] = useState('A');
setInnerText = _setText;

// This will log if the component commits in an inconsistent state


useEffect(() => {
if (text === outerText) {
Scheduler.log('Commit Child');
} else {
Scheduler.log('FIXME: Texts are inconsistent (tearing)');
}
}, [text, outerText]);

return (
<>
<AsyncText text={'Inner text: ' + text} />
<Text text={'Inner step: ' + step} />
</>
);
}

// 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 placeholder
await advanceTimers(250);
expect(root).toMatchRenderedOutput(
<>
<span prop="Outer text: B" />
<span prop="Outer step: 0" />
<span hidden={true} prop="Inner text: A" />
<span hidden={true} prop="Inner step: 0" />
<span prop="Loading..." />
</>,
);

// 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 text


await act(async () => {
await resolveText('Inner text: B');
});
assertLog(['Inner text: B', 'Inner step: 1', 'Commit Child']);
expect(root).toMatchRenderedOutput(
<>
<span prop="Outer text: B" />
<span prop="Outer step: 1" />
<span prop="Inner text: B" />
<span prop="Inner step: 1" />
</>,
);
},
);

// @gate enableLegacyCache
it('a high pri update can unhide a boundary that suspended at a different level',
async () => {
const {useState, useEffect} = React;
const root = ReactNoop.createRoot();

let setOuterText;
function Parent({step}) {
const [text, _setText] = useState('A');
setOuterText = _setText;
return (
<>
<Text text={'Outer: ' + text + step} />
<Suspense fallback={<Text text="Loading..." />}>
<Child step={step} outerText={text} />
</Suspense>
</>
);
}

let setInnerText;
function Child({step, outerText}) {
const [text, _setText] = useState('A');
setInnerText = _setText;

// This will log if the component commits in an inconsistent state


useEffect(() => {
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 placeholder
await 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 forceConcurrentByDefaultForTesting
it('regression: ping at high priority causes update to be dropped', async () => {
const {useState, useTransition} = React;

let setTextA;
function A() {
const [textA, _setTextA] = useState('A');
setTextA = _setTextA;
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={textA} />
</Suspense>
);
}

let setTextB;
let startTransitionFromB;
function B() {
const [textB, _setTextB] = useState('B');
// eslint-disable-next-line no-unused-vars
const [_, _startTransition] = useTransition();
startTransitionFromB = _startTransition;
setTextB = _setTextB;
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={textB} />
</Suspense>
);
}

function App() {
return (
<>
<A />
<B />
</>
);
}

const root = ReactNoop.createRoot();


await act(async () => {
await seedNextTextCache('A');
await seedNextTextCache('B');
root.render(<App />);
});
assertLog(['A', 'B']);
expect(root).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="B" />
</>,
);

await act(async () => {


// Triggers suspense at normal pri
setTextA('A1');
// Triggers in an unrelated tree at a different pri
startTransitionFromB(() => {
// 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 enableLegacyCache
it('does not get stuck in pending state with render phase updates', async () => {
let setTextWithShortTransition;
let setTextWithLongTransition;

function App() {
const [isPending1, startShortTransition] = React.useTransition();
const [isPending2, startLongTransition] = React.useTransition();
const isPending = isPending1 || isPending2;
const [text, setText] = React.useState('');
const [mirror, setMirror] = React.useState('');

if (text !== mirror) {


// 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 enableLegacyCache
it('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 enableLegacyCache
it('retries have lower priority than normal updates', async () => {
const {useState} = React;

let setText;
function UpdatingText() {
const [text, _setText] = useState('A');
setText = _setText;
return <Text text={text} />;
}

const root = ReactNoop.createRoot();


await act(() => {
root.render(
<>
<UpdatingText />
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Async" />
</Suspense>
</>,
);
});
assertLog(['A', 'Suspend! [Async]', 'Loading...']);
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 enableLegacyCache
it('should fire effect clean-up when deleting suspended tree', async () => {
const {useEffect} = React;

function App({show}) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<Child />
{show && <AsyncText text="Async" />}
</Suspense>
);
}

function Child() {
useEffect(() => {
Scheduler.log('Mount Child');
return () => {
Scheduler.log('Unmount Child');
};
}, []);
return <span prop="Child" />;
}

const root = ReactNoop.createRoot();

await act(() => {


root.render(<App show={false} />);
});
assertLog(['Mount Child']);
expect(root).toMatchRenderedOutput(<span prop="Child" />);

await act(() => {


root.render(<App show={true} />);
});
assertLog(['Suspend! [Async]', 'Loading...']);
expect(root).toMatchRenderedOutput(
<>
<span hidden={true} prop="Child" />
<span prop="Loading..." />
</>,
);

await act(() => {


root.render(null);
});
assertLog(['Unmount Child']);
});

// @gate enableLegacyCache
it('should fire effect clean-up when deleting suspended tree (legacy)', async ()
=> {
const {useEffect} = React;

function App({show}) {
return (
<Suspense fallback={<Text text="Loading..." />}>
<Child />
{show && <AsyncText text="Async" />}
</Suspense>
);
}

function Child() {
useEffect(() => {
Scheduler.log('Mount Child');
return () => {
Scheduler.log('Unmount Child');
};
}, []);
return <span prop="Child" />;
}

const root = ReactNoop.createLegacyRoot();

await act(() => {


root.render(<App show={false} />);
});
assertLog(['Mount Child']);
expect(root).toMatchRenderedOutput(<span prop="Child" />);
await act(() => {
root.render(<App show={true} />);
});
assertLog(['Suspend! [Async]', 'Loading...']);
expect(root).toMatchRenderedOutput(
<>
<span hidden={true} prop="Child" />
<span prop="Loading..." />
</>,
);

await act(() => {


root.render(null);
});
assertLog(['Unmount Child']);
});

// @gate enableLegacyCache
it(
'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>,
);
},
);
});

You might also like