let React;
let ReactNoop;
let Scheduler;
let act;
let assertLog;
let waitFor;
let waitForPaint;
let waitForAll;
let textCache;
let startTransition;
let Suspense;
let Activity;
describe('ReactSiblingPrerendering', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactNoop = require('react-noop-renderer');
Scheduler = require('scheduler');
act = require('internal-test-utils').act;
assertLog = require('internal-test-utils').assertLog;
waitFor = require('internal-test-utils').waitFor;
waitForPaint = require('internal-test-utils').waitForPaint;
waitForAll = require('internal-test-utils').waitForAll;
startTransition = React.startTransition;
Suspense = React.Suspense;
Activity = React.unstable_Activity;
textCache = new Map();
});
function resolveText(text) {
const record = textCache.get(text);
if (record === undefined) {
const newRecord = {
status: 'resolved',
value: text,
};
textCache.set(text, newRecord);
} else if (record.status === 'pending') {
const thenable = record.value;
record.status = 'resolved';
record.value = text;
thenable.pings.forEach(t => t());
}
}
function readText(text) {
const record = textCache.get(text);
if (record !== undefined) {
switch (record.status) {
case 'pending':
Scheduler.log(`Suspend! [${text}]`);
throw record.value;
case 'rejected':
throw record.value;
case 'resolved':
return record.value;
}
} 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.set(text, newRecord);
throw thenable;
}
}
// function getText(text) {
// const record = textCache.get(text);
// if (record === undefined) {
// 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.set(text, newRecord);
// return thenable;
// } else {
// switch (record.status) {
// case 'pending':
// return record.value;
// case 'rejected':
// return Promise.reject(record.value);
// case 'resolved':
// return Promise.resolve(record.value);
// }
// }
// }
function Text({text}) {
Scheduler.log(text);
return text;
}
function AsyncText({text}) {
readText(text);
Scheduler.log(text);
return text;
}
it("don't prerender siblings when something errors", async () => {
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
if (this.state.error) {
return <Text text={this.state.error.message} />;
}
return this.props.children;
}
}
function Oops() {
throw new Error('Oops!');
}
function App() {
return (
<>
<div>
<ErrorBoundary>
<Oops />
<AsyncText text="A" />
</ErrorBoundary>
</div>
<div>
<AsyncText text="B" />
<AsyncText text="C" />
</div>
</>
);
}
const root = ReactNoop.createRoot();
await act(() => startTransition(() => root.render(<App />)));
assertLog([
'Oops!',
// A is skipped because we don't prerender siblings when
// something errors.
'Suspend! [B]',
// After B suspends, we're still able to prerender C without starting
// over because there's no fallback, so the root is blocked from
// committing anyway.
...(gate('enableSiblingPrerendering') ? ['Suspend! [C]'] : []),
]);
});
// @gate enableActivity
it("don't skip siblings when rendering inside a hidden tree", async () => {
function App() {
return (
<>
<Text text="A" />
<Activity mode="hidden">
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="B" />
<AsyncText text="C" />
</Suspense>
</Activity>
</>
);
}
const root = ReactNoop.createRoot();
await act(async () => {
startTransition(async () => root.render(<App />));
// The first render includes only the visible part of the tree. The
// hidden content is deferred until later.
await waitForPaint(['A']);
expect(root).toMatchRenderedOutput('A');
// The second render is a prerender of the hidden content.
await waitForPaint([
'Suspend! [B]',
// If B and C were visible, C would not have been attempted
// during this pass, because it would prevented the fallback
// from showing.
...(gate('enableSiblingPrerendering') ? ['Suspend! [C]'] : []),
'Loading...',
]);
expect(root).toMatchRenderedOutput('A');
});
});
it('start prerendering retries right after the fallback commits', async () => {
function App() {
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="A" />
<AsyncText text="B" />
</Suspense>
);
}
const root = ReactNoop.createRoot();
await act(async () => {
startTransition(() => root.render(<App />));
// On the first attempt, A suspends. Unwind and show a fallback, without
// attempting B.
await waitForPaint(['Suspend! [A]', 'Loading...']);
expect(root).toMatchRenderedOutput('Loading...');
// Immediately after the fallback commits, retry the boundary again. This
// time we include B, since we're not blocking the fallback from showing.
if (gate('enableSiblingPrerendering')) {
await waitForPaint(['Suspend! [A]', 'Suspend! [B]']);
}
});
expect(root).toMatchRenderedOutput('Loading...');
});
it('switch back to normal rendering mode if a ping occurs during prerendering', async () => {
function App() {
return (
<div>
<Suspense fallback={<Text text="Loading outer..." />}>
<div>
<Text text="A" />
<AsyncText text="B" />
</div>
<div>
<Suspense fallback={<Text text="Loading inner..." />}>
<AsyncText text="C" />
<AsyncText text="D" />
</Suspense>
</div>
</Suspense>
</div>
);
}
const root = ReactNoop.createRoot();
await act(async () => {
startTransition(() => root.render(<App />));
// On the first attempt, B suspends. Unwind and show a fallback, without
// attempting the siblings.
await waitForPaint(['A', 'Suspend! [B]', 'Loading outer...']);
expect(root).toMatchRenderedOutput(<div>Loading outer...</div>);
// Now that the fallback is visible, we can prerender the siblings. Start
// prerendering, then yield to simulate an interleaved event.
if (gate('enableSiblingPrerendering')) {
await waitFor(['A']);
} else {
await waitForAll([]);
}
// To avoid the Suspense throttling mechanism, let's pretend there's been
// more than a Just Noticeable Difference since we rendered the
// outer fallback.
Scheduler.unstable_advanceTime(500);
// During the render phase, but before we get to B again, resolve its
// promise. We should re-enter normal rendering mode, but we also
// shouldn't unwind and lose our work-in-progress.
await resolveText('B');
await waitForPaint([
// When sibling prerendering is not enabled, we weren't already rendering
// when the data for B came in, so A doesn't get rendered until now.
...(gate('enableSiblingPrerendering') ? [] : ['A']),
'B',
'Suspend! [C]',
// If we were still in prerendering mode, then we would have attempted
// to render D here. But since we received new data, we will skip the
// remaining siblings to unblock the inner fallback.
'Loading inner...',
]);
expect(root).toMatchRenderedOutput(
<div>
<div>AB</div>
<div>Loading inner...</div>
</div>,
);
});
// Now that the inner fallback is showing, we can prerender the rest of
// the tree.
assertLog(
gate('enableSiblingPrerendering')
? [
// NOTE: C renders twice instead of once because when B resolved, it
// was treated like a retry update, not just a ping. So first it
// regular renders, then it prerenders. TODO: We should be able to
// optimize this by detecting inside the retry listener that the
// outer boundary is no longer suspended, and therefore doesn't need
// to be updated.
'Suspend! [C]',
// Now we're in prerender mode, so D is incuded in this attempt.
'Suspend! [C]',
'Suspend! [D]',
]
: [],
);
expect(root).toMatchRenderedOutput(
<div>
<div>AB</div>
<div>Loading inner...</div>
</div>,
);
});
it("don't throw out completed work in order to prerender", async () => {
function App() {
return (
<div>
<Suspense fallback={<Text text="Loading outer..." />}>
<div>
<AsyncText text="A" />
</div>
<div>
<Suspense fallback={<Text text="Loading inner..." />}>
<AsyncText text="B" />
<AsyncText text="C" />
</Suspense>
</div>
</Suspense>
</div>
);
}
const root = ReactNoop.createRoot();
await act(async () => {
startTransition(() => root.render(<App />));
await waitForPaint(['Suspend! [A]', 'Loading outer...']);
expect(root).toMatchRenderedOutput(<div>Loading outer...</div>);
// Before the prerendering of the inner boundary starts, the data for A
// resolves, so we try rendering that again.
await resolveText('A');
// This produces a new tree that we can show. However, the commit phase
// is throttled because it's been less than a Just Noticeable Difference
// since the outer fallback was committed.
//
// In the meantime, we could choose to start prerendering C, but instead
// we wait for a JND to elapse and the commit to finish — it's not
// worth discarding the work we've already done.
await waitForAll([
'A',
'Suspend! [B]',
// C is skipped because we're no longer in prerendering mode; there's
// a new fallback we can show.
'Loading inner...',
]);
expect(root).toMatchRenderedOutput(<div>Loading outer...</div>);
// Fire the timer to commit the outer fallback.
jest.runAllTimers();
expect(root).toMatchRenderedOutput(
<div>
<div>A</div>
<div>Loading inner...</div>
</div>,
);
});
// Once the inner fallback is committed, we can start prerendering C.
assertLog(
gate('enableSiblingPrerendering') ? ['Suspend! [B]', 'Suspend! [C]'] : [],
);
});
it(
"don't skip siblings during the retry if there was a ping since the " +
'first attempt',
async () => {
function App() {
return (
<>
<div>
<Suspense fallback={<Text text="Loading outer..." />}>
<div>
<AsyncText text="A" />
</div>
<div>
<Suspense fallback={<Text text="Loading inner..." />}>
<AsyncText text="B" />
<AsyncText text="C" />
</Suspense>
</div>
</Suspense>
</div>
<div>
<Text text="D" />
</div>
</>
);
}
const root = ReactNoop.createRoot();
await act(async () => {
startTransition(() => root.render(<App />));
// On the first attempt, A suspends. Unwind and show a fallback, without
// attempting B or C.
await waitFor([
'Suspend! [A]',
'Loading outer...',
// Yield to simulate an interleaved event
]);
// Ping the promise for A before the render phase has finished, as might
// happen in an interleaved network event
await resolveText('A');
// Now continue rendering the rest of the tree.
await waitForPaint(['D']);
expect(root).toMatchRenderedOutput(
<>
<div>Loading outer...</div>
<div>D</div>
</>,
);
// Immediately after the fallback commits, retry the boundary again.
// Because the promise for A resolved, this is a normal render, _not_
// a prerender. So when we proceed to B, and B suspends, we unwind again
// without attempting C. The practical benefit of this is that we don't
// block the inner Suspense fallback from appearing.
await waitForPaint(['A', 'Suspend! [B]', 'Loading inner...']);
// (Since this is a retry, the commit phase is throttled by a timer.)
jest.runAllTimers();
// The inner fallback is now visible.
expect(root).toMatchRenderedOutput(
<>
<div>
<div>A</div>
<div>Loading inner...</div>
</div>
<div>D</div>
</>,
);
// Now we can proceed to prerendering C.
if (gate('enableSiblingPrerendering')) {
await waitForPaint(['Suspend! [B]', 'Suspend! [C]']);
}
});
assertLog([]);
},
);
});