3986 lines
110 KiB
JavaScript
3986 lines
110 KiB
JavaScript
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 () => {
|
|
if (gate(flags => flags.enableSyncDefaultUpdates)) {
|
|
React.startTransition(() => {
|
|
root.render(<App step={1} shouldSuspend={true} />);
|
|
});
|
|
} else {
|
|
root.render(<App step={1} shouldSuspend={true} />);
|
|
}
|
|
Scheduler.unstable_advanceTime(1000);
|
|
await waitFor(['Sibling']);
|
|
interrupt();
|
|
|
|
if (gate(flags => flags.enableSyncDefaultUpdates)) {
|
|
React.startTransition(() => {
|
|
root.render(<App step={2} shouldSuspend={true} />);
|
|
});
|
|
} else {
|
|
root.render(<App step={2} shouldSuspend={true} />);
|
|
}
|
|
Scheduler.unstable_advanceTime(1000);
|
|
await waitFor(['Sibling']);
|
|
interrupt();
|
|
|
|
if (gate(flags => flags.enableSyncDefaultUpdates)) {
|
|
React.startTransition(() => {
|
|
root.render(<App step={3} shouldSuspend={true} />);
|
|
});
|
|
} else {
|
|
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);
|
|
if (gate(flags => flags.enableSyncDefaultUpdates)) {
|
|
React.startTransition(() => {
|
|
root.render(
|
|
<>
|
|
<Suspense fallback={<Text text="Loading..." />}>
|
|
<AsyncText text="Async" />
|
|
<Text text="Sibling" />
|
|
</Suspense>
|
|
</>,
|
|
);
|
|
});
|
|
} else {
|
|
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" />);
|
|
});
|
|
|
|
// TODO: This test was written against the old Expiration Times
|
|
// implementation. It doesn't really test what it was intended to test
|
|
// anymore, because all updates to the same queue get entangled together.
|
|
// Even if they haven't expired. Consider either deleting or rewriting.
|
|
// @gate enableLegacyCache
|
|
it('flushes all expired updates in a single batch', async () => {
|
|
class Foo extends React.Component {
|
|
componentDidUpdate() {
|
|
Scheduler.log('Commit: ' + this.props.text);
|
|
}
|
|
componentDidMount() {
|
|
Scheduler.log('Commit: ' + this.props.text);
|
|
}
|
|
render() {
|
|
return (
|
|
<Suspense fallback={<Text text="Loading..." />}>
|
|
<AsyncText text={this.props.text} />
|
|
</Suspense>
|
|
);
|
|
}
|
|
}
|
|
|
|
ReactNoop.render(<Foo text="" />);
|
|
ReactNoop.expire(1000);
|
|
jest.advanceTimersByTime(1000);
|
|
ReactNoop.render(<Foo text="go" />);
|
|
ReactNoop.expire(1000);
|
|
jest.advanceTimersByTime(1000);
|
|
ReactNoop.render(<Foo text="good" />);
|
|
ReactNoop.expire(1000);
|
|
jest.advanceTimersByTime(1000);
|
|
ReactNoop.render(<Foo text="goodbye" />);
|
|
|
|
await waitForAll(['Suspend! [goodbye]', 'Loading...', 'Commit: goodbye']);
|
|
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
|
|
|
|
await resolveText('goodbye');
|
|
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
|
|
|
|
await waitForAll(['goodbye']);
|
|
expect(ReactNoop).toMatchRenderedOutput(<span prop="goodbye" />);
|
|
});
|
|
|
|
// @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('does suspend if a fallback has been shown for a short 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..." />);
|
|
|
|
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']);
|
|
// 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.
|
|
//
|
|
// Correct output:
|
|
// expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
|
|
//
|
|
// Actual output:
|
|
expect(ReactNoop).toMatchRenderedOutput(
|
|
<>
|
|
<span prop="A" />
|
|
<span prop="B" />
|
|
</>,
|
|
);
|
|
});
|
|
assertLog([]);
|
|
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.enableSyncDefaultUpdates)) {
|
|
React.startTransition(() => {
|
|
ReactNoop.render(<Foo showB={true} />);
|
|
});
|
|
} else {
|
|
ReactNoop.render(<Foo showB={true} />);
|
|
}
|
|
|
|
await waitForAll(['Foo', 'A', 'Suspend! [B]', 'Loading B...']);
|
|
|
|
if (gate(flags => flags.enableSyncDefaultUpdates)) {
|
|
// Transitions never fall back.
|
|
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
|
|
} else {
|
|
expect(ReactNoop).toMatchRenderedOutput(
|
|
<>
|
|
<span prop="A" />
|
|
<span prop="Loading B..." />
|
|
</>,
|
|
);
|
|
}
|
|
});
|
|
|
|
// @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" />);
|
|
|
|
if (gate(flags => flags.enableSyncDefaultUpdates)) {
|
|
React.startTransition(() => {
|
|
ReactNoop.render(<Foo showB={true} />);
|
|
});
|
|
} else {
|
|
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);
|
|
|
|
if (gate(flags => flags.enableSyncDefaultUpdates)) {
|
|
// Transitions never fall back.
|
|
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
|
|
} else {
|
|
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 !enableSyncDefaultUpdates
|
|
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>,
|
|
);
|
|
},
|
|
);
|
|
});
|