let React;
let ReactNoop;
let Scheduler;
let act;
let Suspense;
let getCacheForType;
let startTransition;
let assertLog;
let caches;
let seededCache;
describe('ReactConcurrentErrorRecovery', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
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');
assertLog = InternalTestUtils.assertLog;
getCacheForType = React.unstable_getCacheForType;
caches = [];
seededCache = null;
});
function createTextCache() {
if (seededCache !== null) {
const cache = seededCache;
seededCache = null;
return cache;
}
const data = new Map();
const version = caches.length + 1;
const cache = {
version,
data,
resolve(text) {
const record = data.get(text);
if (record === undefined) {
const newRecord = {
status: 'resolved',
value: text,
};
data.set(text, newRecord);
} else if (record.status === 'pending') {
const thenable = record.value;
record.status = 'resolved';
record.value = text;
thenable.pings.forEach(t => t());
}
},
reject(text, error) {
const record = data.get(text);
if (record === undefined) {
const newRecord = {
status: 'rejected',
value: error,
};
data.set(text, newRecord);
} else if (record.status === 'pending') {
const thenable = record.value;
record.status = 'rejected';
record.value = error;
thenable.pings.forEach(t => t());
}
},
};
caches.push(cache);
return cache;
}
function readText(text) {
const textCache = getCacheForType(createTextCache);
const record = textCache.data.get(text);
if (record !== undefined) {
switch (record.status) {
case 'pending':
Scheduler.log(`Suspend! [${text}]`);
throw record.value;
case 'rejected':
Scheduler.log(`Error! [${text}]`);
throw record.value;
case 'resolved':
return textCache.version;
}
} else {
Scheduler.log(`Suspend! [${text}]`);
const thenable = {
pings: [],
then(resolve) {
if (newRecord.status === 'pending') {
thenable.pings.push(resolve);
} else {
Promise.resolve().then(() => resolve(newRecord.value));
}
},
};
const newRecord = {
status: 'pending',
value: thenable,
};
textCache.data.set(text, newRecord);
throw thenable;
}
}
function Text({text}) {
Scheduler.log(text);
return text;
}
function AsyncText({text, showVersion}) {
const version = readText(text);
const fullText = showVersion ? `${text} [v${version}]` : text;
Scheduler.log(fullText);
return fullText;
}
function seedNextTextCache(text) {
if (seededCache === null) {
seededCache = createTextCache();
}
seededCache.resolve(text);
}
function resolveMostRecentTextCache(text) {
if (caches.length === 0) {
throw Error('Cache does not exist.');
} else {
caches[caches.length - 1].resolve(text);
}
}
const resolveText = resolveMostRecentTextCache;
function rejectMostRecentTextCache(text, error) {
if (caches.length === 0) {
throw Error('Cache does not exist.');
} else {
caches[caches.length - 1].reject(text, error);
}
}
const rejectText = rejectMostRecentTextCache;
it('errors during a refresh transition should not force fallbacks to display (suspend then error)', async () => {
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
if (this.state.error !== null) {
return <Text text={this.state.error.message} />;
}
return this.props.children;
}
}
function App({step}) {
return (
<>
<Suspense fallback={<Text text="Loading..." />}>
<ErrorBoundary>
<AsyncText text={'A' + step} />
</ErrorBoundary>
</Suspense>
<Suspense fallback={<Text text="Loading..." />}>
<ErrorBoundary>
<AsyncText text={'B' + step} />
</ErrorBoundary>
</Suspense>
</>
);
}
const root = ReactNoop.createRoot();
seedNextTextCache('A1');
seedNextTextCache('B1');
await act(() => {
root.render(<App step={1} />);
});
assertLog(['A1', 'B1']);
expect(root).toMatchRenderedOutput('A1B1');
await act(() => {
startTransition(() => {
root.render(<App step={2} />);
});
});
assertLog(['Suspend! [A2]', 'Loading...', 'Suspend! [B2]', 'Loading...']);
expect(root).toMatchRenderedOutput('A1B1');
await act(() => {
rejectText('B2', new Error('Oops!'));
});
assertLog(['Suspend! [A2]', 'Loading...', 'Error! [B2]', 'Oops!']);
expect(root).toMatchRenderedOutput('A1B1');
await act(() => {
resolveText('A2');
});
assertLog(['A2', 'Error! [B2]', 'Oops!', 'A2', 'Error! [B2]', 'Oops!']);
expect(root).toMatchRenderedOutput('A2Oops!');
});
it('errors during a refresh transition should not force fallbacks to display (error then suspend)', async () => {
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
if (this.state.error !== null) {
return <Text text={this.state.error.message} />;
}
return this.props.children;
}
}
function App({step}) {
return (
<>
<Suspense fallback={<Text text="Loading..." />}>
<ErrorBoundary>
<AsyncText text={'A' + step} />
</ErrorBoundary>
</Suspense>
<Suspense fallback={<Text text="Loading..." />}>
<ErrorBoundary>
<AsyncText text={'B' + step} />
</ErrorBoundary>
</Suspense>
</>
);
}
const root = ReactNoop.createRoot();
seedNextTextCache('A1');
seedNextTextCache('B1');
await act(() => {
root.render(<App step={1} />);
});
assertLog(['A1', 'B1']);
expect(root).toMatchRenderedOutput('A1B1');
await act(() => {
startTransition(() => {
root.render(<App step={2} />);
});
});
assertLog(['Suspend! [A2]', 'Loading...', 'Suspend! [B2]', 'Loading...']);
expect(root).toMatchRenderedOutput('A1B1');
await act(() => {
rejectText('A2', new Error('Oops!'));
});
assertLog(['Error! [A2]', 'Oops!', 'Suspend! [B2]', 'Loading...']);
expect(root).toMatchRenderedOutput('A1B1');
await act(() => {
resolveText('B2');
});
assertLog(['Error! [A2]', 'Oops!', 'B2', 'Error! [A2]', 'Oops!', 'B2']);
expect(root).toMatchRenderedOutput('Oops!B2');
});
it('suspending in the shell (outside a Suspense boundary) should not throw, warn, or log during a transition', async () => {
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
if (this.state.error !== null) {
return <Text text={this.state.error.message} />;
}
return this.props.children;
}
}
const root = ReactNoop.createRoot();
await act(() => {
startTransition(() => {
root.render(<AsyncText text="Async" />);
});
});
assertLog(['Suspend! [Async]']);
expect(root).toMatchRenderedOutput(null);
await act(() => {
startTransition(() => {
root.render(
<ErrorBoundary>
<AsyncText text="Async" />
</ErrorBoundary>,
);
});
});
assertLog(['Suspend! [Async]']);
expect(root).toMatchRenderedOutput(null);
await act(() => {
resolveText('Async');
});
assertLog(['Async']);
expect(root).toMatchRenderedOutput('Async');
});
it(
'errors during a suspended transition at the shell should not force ' +
'fallbacks to display (error then suspend)',
async () => {
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
if (this.state.error !== null) {
return (
<Text text={'Caught an error: ' + this.state.error.message} />
);
}
return this.props.children;
}
}
function Throws() {
throw new Error('Oops!');
}
const root = ReactNoop.createRoot();
await act(() => {
startTransition(() => {
root.render(
<>
<AsyncText text="Async" />
<ErrorBoundary>
<Throws />
</ErrorBoundary>
</>,
);
});
});
assertLog([
'Suspend! [Async]',
...(gate('enableSiblingPrerendering')
? ['Caught an error: Oops!']
: []),
]);
expect(root).toMatchRenderedOutput(null);
await act(() => {
startTransition(() => {
root.render(
<>
<AsyncText text="Async" />
<ErrorBoundary>
<Throws />
</ErrorBoundary>
</>,
);
});
});
assertLog([
'Suspend! [Async]',
...(gate('enableSiblingPrerendering')
? ['Caught an error: Oops!']
: []),
]);
expect(root).toMatchRenderedOutput(null);
await act(async () => {
await resolveText('Async');
});
assertLog([
'Async',
'Caught an error: Oops!',
'Async',
'Caught an error: Oops!',
]);
expect(root).toMatchRenderedOutput('AsyncCaught an error: Oops!');
},
);
});