'use strict';
import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel';
import {
getVisibleChildren,
insertNodesAndExecuteScripts,
} from '../test-utils/FizzTestUtils';
global.ReadableStream =
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
global.TextEncoder = require('util').TextEncoder;
global.TextDecoder = require('util').TextDecoder;
let JSDOM;
let React;
let ReactDOMFizzServer;
let ReactDOMFizzStatic;
let Suspense;
let SuspenseList;
let container;
let serverAct;
let assertConsoleErrorDev;
describe('ReactDOMFizzStaticBrowser', () => {
beforeEach(() => {
jest.resetModules();
JSDOM = require('jsdom').JSDOM;
window.setTimeout = setTimeout;
window.requestAnimationFrame = setTimeout;
patchMessageChannel();
serverAct = require('internal-test-utils').serverAct;
assertConsoleErrorDev =
require('internal-test-utils').assertConsoleErrorDev;
React = require('react');
ReactDOMFizzServer = require('react-dom/server.browser');
ReactDOMFizzStatic = require('react-dom/static.browser');
Suspense = React.Suspense;
SuspenseList = React.unstable_SuspenseList;
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
if (typeof global.window.__restoreGlobalScope === 'function') {
global.window.__restoreGlobalScope();
}
document.body.removeChild(container);
});
const theError = new Error('This is an error');
function Throw() {
throw theError;
}
const theInfinitePromise = new Promise(() => {});
function InfiniteSuspend() {
throw theInfinitePromise;
}
async function readContent(stream) {
const reader = stream.getReader();
let content = '';
while (true) {
const {done, value} = await reader.read();
if (done) {
return content;
}
content += Buffer.from(value).toString('utf8');
}
}
async function readIntoContainer(stream) {
const reader = stream.getReader();
let result = '';
while (true) {
const {done, value} = await reader.read();
if (done) {
break;
}
result += Buffer.from(value).toString('utf8');
}
const temp = document.createElement('div');
temp.innerHTML = result;
await insertNodesAndExecuteScripts(temp, container, null);
jest.runAllTimers();
}
async function readIntoNewDocument(stream) {
const content = await readContent(stream);
const jsdom = new JSDOM(
'<script>window.requestAnimationFrame = setTimeout;</script>' + content,
{
runScripts: 'dangerously',
},
);
const originalWindow = global.window;
const originalDocument = global.document;
const originalNavigator = global.navigator;
const originalNode = global.Node;
const originalAddEventListener = global.addEventListener;
const originalMutationObserver = global.MutationObserver;
global.window = jsdom.window;
global.document = global.window.document;
global.navigator = global.window.navigator;
global.Node = global.window.Node;
global.addEventListener = global.window.addEventListener;
global.MutationObserver = global.window.MutationObserver;
global.window.__restoreGlobalScope = () => {
global.window = originalWindow;
global.document = originalDocument;
global.navigator = originalNavigator;
global.Node = originalNode;
global.addEventListener = originalAddEventListener;
global.MutationObserver = originalMutationObserver;
};
}
async function readIntoCurrentDocument(stream) {
const content = await readContent(stream);
const temp = document.createElement('div');
temp.innerHTML = content;
await insertNodesAndExecuteScripts(temp, document.body, null);
jest.runAllTimers();
}
it('should call prerender', async () => {
const result = await serverAct(() =>
ReactDOMFizzStatic.prerender(<div>hello world</div>),
);
const prelude = await readContent(result.prelude);
expect(prelude).toMatchInlineSnapshot(`"<div>hello world</div>"`);
});
it('should emit DOCTYPE at the root of the document', async () => {
const result = await serverAct(() =>
ReactDOMFizzStatic.prerender(
<html>
<body>hello world</body>
</html>,
),
);
const prelude = await readContent(result.prelude);
if (gate(flags => flags.enableFizzBlockingRender)) {
expect(prelude).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><head><link rel="expect" href="#_R_" blocking="render"/></head><body>hello world<template id="_R_"></template></body></html>"`,
);
} else {
expect(prelude).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
);
}
});
it('should emit bootstrap script src at the end', async () => {
const result = await serverAct(() =>
ReactDOMFizzStatic.prerender(<div>hello world</div>, {
bootstrapScriptContent: 'INIT();',
bootstrapScripts: ['init.js'],
bootstrapModules: ['init.mjs'],
}),
);
const prelude = await readContent(result.prelude);
expect(prelude).toMatchInlineSnapshot(
`"<link rel="preload" as="script" fetchPriority="low" href="init.js"/><link rel="modulepreload" fetchPriority="low" href="init.mjs"/><div>hello world</div><script id="_R_">INIT();</script><script src="init.js" async=""></script><script type="module" src="init.mjs" async=""></script>"`,
);
});
it('emits all HTML as one unit', async () => {
let hasLoaded = false;
let resolve;
const promise = new Promise(r => (resolve = r));
function Wait() {
if (!hasLoaded) {
throw promise;
}
return 'Done';
}
const resultPromise = serverAct(() =>
ReactDOMFizzStatic.prerender(
<div>
<Suspense fallback="Loading">
<Wait />
</Suspense>
</div>,
),
);
await jest.runAllTimers();
hasLoaded = true;
await resolve();
const result = await resultPromise;
const prelude = await readContent(result.prelude);
expect(prelude).toMatchInlineSnapshot(`"<div><!--$-->Done<!--/$--></div>"`);
});
it('should reject the promise when an error is thrown at the root', async () => {
const reportedErrors = [];
let caughtError = null;
try {
await serverAct(() =>
ReactDOMFizzStatic.prerender(
<div>
<Throw />
</div>,
{
onError(x) {
reportedErrors.push(x);
},
},
),
);
} catch (error) {
caughtError = error;
}
expect(caughtError).toBe(theError);
expect(reportedErrors).toEqual([theError]);
});
it('should reject the promise when an error is thrown inside a fallback', async () => {
const reportedErrors = [];
let caughtError = null;
try {
await serverAct(() =>
ReactDOMFizzStatic.prerender(
<div>
<Suspense fallback={<Throw />}>
<InfiniteSuspend />
</Suspense>
</div>,
{
onError(x) {
reportedErrors.push(x);
},
},
),
);
} catch (error) {
caughtError = error;
}
expect(caughtError).toBe(theError);
expect(reportedErrors).toEqual([theError]);
});
it('should not error the stream when an error is thrown inside suspense boundary', async () => {
const reportedErrors = [];
const result = await serverAct(() =>
ReactDOMFizzStatic.prerender(
<div>
<Suspense fallback={<div>Loading</div>}>
<Throw />
</Suspense>
</div>,
{
onError(x) {
reportedErrors.push(x);
},
},
),
);
const prelude = await readContent(result.prelude);
expect(prelude).toContain('Loading');
expect(reportedErrors).toEqual([theError]);
});
it('should be able to complete by aborting even if the promise never resolves', async () => {
const errors = [];
const controller = new AbortController();
let resultPromise;
await serverAct(() => {
resultPromise = ReactDOMFizzStatic.prerender(
<div>
<Suspense fallback={<div>Loading</div>}>
<InfiniteSuspend />
</Suspense>
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
},
);
});
await serverAct(() => {
controller.abort();
});
const result = await resultPromise;
const prelude = await readContent(result.prelude);
expect(prelude).toContain('Loading');
expect(errors).toEqual(['This operation was aborted']);
});
it('should resolve an empty prelude if aborting before the shell is complete', async () => {
const errors = [];
const controller = new AbortController();
const promise = serverAct(() =>
ReactDOMFizzStatic.prerender(
<div>
<InfiniteSuspend />
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
},
),
);
await jest.runAllTimers();
const theReason = new Error('aborted for reasons');
await serverAct(() => {
controller.abort(theReason);
});
let rejected = false;
let prelude;
try {
({prelude} = await promise);
} catch (error) {
rejected = true;
}
expect(rejected).toBe(false);
expect(errors).toEqual(['aborted for reasons']);
const content = await readContent(prelude);
expect(content).toBe('');
});
it('should be able to abort before something suspends', async () => {
const errors = [];
const controller = new AbortController();
function App() {
controller.abort();
return (
<Suspense fallback={<div>Loading</div>}>
<InfiniteSuspend />
</Suspense>
);
}
const streamPromise = serverAct(() =>
ReactDOMFizzStatic.prerender(
<div>
<App />
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
},
),
);
const {prelude} = await streamPromise;
const content = await readContent(prelude);
expect(errors).toEqual(['This operation was aborted']);
expect(content).toBe('');
});
it('reports the abort reason if a task suspends after aborting a prerender', async () => {
const promise = new Promise(() => {});
const errors = [];
const controller = new AbortController();
function App() {
controller.abort(new Error('abort reason'));
React.use(promise);
return null;
}
const result = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError(error) {
errors.push(error.message);
},
}),
);
expect(errors).toEqual(['abort reason']);
expect(await readContent(result.prelude)).toBe('');
});
it('should resolve an empty prelude if passing an already aborted signal', async () => {
const errors = [];
const controller = new AbortController();
const theReason = new Error('aborted for reasons');
controller.abort(theReason);
const promise = serverAct(() =>
ReactDOMFizzStatic.prerender(
<div>
<Suspense fallback={<div>Loading</div>}>
<InfiniteSuspend />
</Suspense>
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
},
),
);
let didThrow = false;
let prelude;
try {
({prelude} = await promise);
} catch (error) {
didThrow = true;
}
expect(didThrow).toBe(false);
expect(errors).toEqual(['aborted for reasons']);
const content = await readContent(prelude);
expect(content).toBe('');
});
it('supports custom abort reasons with a string', async () => {
const promise = new Promise(r => {});
function Wait() {
throw promise;
}
function App() {
return (
<div>
<p>
<Suspense fallback={'p'}>
<Wait />
</Suspense>
</p>
<span>
<Suspense fallback={'span'}>
<Wait />
</Suspense>
</span>
</div>
);
}
const errors = [];
const controller = new AbortController();
let resultPromise;
await serverAct(() => {
resultPromise = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError(x) {
errors.push(x);
return 'a digest';
},
});
});
await serverAct(() => {
controller.abort('foobar');
});
await resultPromise;
expect(errors).toEqual(['foobar', 'foobar']);
});
it('supports custom abort reasons with an Error', async () => {
const promise = new Promise(r => {});
function Wait() {
throw promise;
}
function App() {
return (
<div>
<p>
<Suspense fallback={'p'}>
<Wait />
</Suspense>
</p>
<span>
<Suspense fallback={'span'}>
<Wait />
</Suspense>
</span>
</div>
);
}
const errors = [];
const controller = new AbortController();
let resultPromise;
await serverAct(() => {
resultPromise = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError(x) {
errors.push(x.message);
return 'a digest';
},
});
});
await serverAct(() => {
controller.abort(new Error('uh oh'));
});
await resultPromise;
expect(errors).toEqual(['uh oh', 'uh oh']);
});
it('uses a rejection reason when an abort listener rejects pending work before the abort finishes', async () => {
let reject;
const rejectedPromise = new Promise((resolve, rejectPromise) => {
reject = rejectPromise;
});
const haltedPromise = new Promise(() => {});
function RejectedWait() {
React.use(rejectedPromise);
return null;
}
function HaltedWait() {
React.use(haltedPromise);
return null;
}
const errors = [];
const controller = new AbortController();
let resultPromise;
await serverAct(() => {
resultPromise = ReactDOMFizzStatic.prerender(
<>
<Suspense fallback="Loading rejected">
<RejectedWait />
</Suspense>
<Suspense fallback="Loading halted">
<HaltedWait />
</Suspense>
</>,
{
signal: controller.signal,
onError(error) {
errors.push(error.message);
},
},
);
});
controller.signal.addEventListener('abort', () => {
reject(new Error('rejected during abort'));
});
await serverAct(() => {
controller.abort(new Error('abort reason'));
});
await resultPromise;
expect(errors).toEqual(['rejected during abort', 'abort reason']);
});
it('logs an error if onHeaders throws but continues the prerender', async () => {
const errors = [];
function onError(error) {
errors.push(error.message);
}
function onHeaders(x) {
throw new Error('bad onHeaders');
}
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<div>hello</div>, {
onHeaders,
onError,
}),
);
expect(prerendered.postponed).toBe(null);
expect(errors).toEqual(['bad onHeaders']);
await readIntoContainer(prerendered.prelude);
expect(getVisibleChildren(container)).toEqual(<div>hello</div>);
});
it('preserves the external runtime configuration when resuming an empty prelude', async () => {
let prerendering = true;
let resolve;
const promise = new Promise(r => (resolve = r));
function Content() {
React.use(promise);
return <p>Content</p>;
}
function Document() {
if (prerendering) {
React.use(theInfinitePromise);
}
return (
<html>
<body>
<Suspense fallback="Loading">
<Content />
</Suspense>
</body>
</html>
);
}
function App() {
return (
<Suspense fallback={null}>
<Document />
</Suspense>
);
}
const prerenderController = new AbortController();
let pendingPrerender;
await serverAct(() => {
pendingPrerender = ReactDOMFizzStatic.prerender(<App />, {
signal: prerenderController.signal,
onError() {},
unstable_externalRuntimeSrc: {
src: '/react-runtime.js',
integrity: 'sha256-runtime',
},
});
});
await serverAct(() => prerenderController.abort());
const prerendered = await pendingPrerender;
expect(prerendered.postponed).not.toBe(null);
expect(await readContent(prerendered.prelude)).toBe('');
const resumeAndPrerenderController = new AbortController();
let pendingResumeAndPrerender;
await serverAct(() => {
pendingResumeAndPrerender = ReactDOMFizzStatic.resumeAndPrerender(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
{
signal: resumeAndPrerenderController.signal,
onError() {},
},
);
});
await serverAct(() => resumeAndPrerenderController.abort());
const resumedAndPrerendered = await pendingResumeAndPrerender;
expect(resumedAndPrerendered.postponed).not.toBe(null);
expect(await readContent(resumedAndPrerendered.prelude)).toBe('');
prerendering = false;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(resumedAndPrerendered.postponed)),
{
nonce: 'resume-nonce',
},
),
);
const reader = resumed.getReader();
const {value, done} = await reader.read();
expect(done).toBe(false);
let html = Buffer.from(value).toString('utf8');
reader.releaseLock();
const remaining = readContent(resumed);
await serverAct(() => {
resolve();
});
html += await remaining;
const doc = new JSDOM(html).window.document;
expect(doc.querySelectorAll('template[data-rci]')).toHaveLength(1);
const scripts = doc.querySelectorAll('script[src="/react-runtime.js"]');
expect(scripts).toHaveLength(1);
expect(scripts[0].hasAttribute('async')).toBe(true);
expect(scripts[0].getAttribute('integrity')).toBe('sha256-runtime');
expect(scripts[0].getAttribute('nonce')).toBe('resume-nonce');
});
it('can resume render of a prerender', async () => {
const errors = [];
let resolveA;
const promiseA = new Promise(r => (resolveA = r));
let resolveB;
const promiseB = new Promise(r => (resolveB = r));
async function ComponentA() {
await promiseA;
return (
<Suspense fallback="Loading B">
<ComponentB />
</Suspense>
);
}
async function ComponentB() {
await promiseB;
return 'Hello';
}
function App() {
return (
<div>
<Suspense fallback="Loading A">
<ComponentA />
</Suspense>
</div>
);
}
const controller = new AbortController();
let pendingResult;
await serverAct(async () => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
});
});
await serverAct(() => {
controller.abort();
});
const prerendered = await pendingResult;
const postponedState = JSON.stringify(prerendered.postponed);
await readIntoContainer(prerendered.prelude);
expect(getVisibleChildren(container)).toEqual(<div>Loading A</div>);
await resolveA();
expect(prerendered.postponed).not.toBe(null);
const controller2 = new AbortController();
await serverAct(async () => {
pendingResult = ReactDOMFizzStatic.resumeAndPrerender(
<App />,
JSON.parse(postponedState),
{
signal: controller2.signal,
onError(x) {
errors.push(x.message);
},
},
);
});
await serverAct(() => {
controller2.abort();
});
const prerendered2 = await pendingResult;
const postponedState2 = JSON.stringify(prerendered2.postponed);
await readIntoContainer(prerendered2.prelude);
expect(getVisibleChildren(container)).toEqual(<div>Loading B</div>);
await resolveB();
const dynamic = await serverAct(() =>
ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState2)),
);
await readIntoContainer(dynamic);
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
});
it('can abort while replaying a prerendered tree', async () => {
const promise = new Promise(() => {});
let prerendering = true;
const resumeController = new AbortController();
function AbortDuringReplay({children}) {
if (!prerendering) {
resumeController.abort('resume abort');
}
return children;
}
function Wait() {
return prerendering ? React.use(promise) : 'Hello';
}
function App() {
return (
<div>
<AbortDuringReplay>
<Suspense fallback="Loading 1...">
<Wait />
</Suspense>
</AbortDuringReplay>
</div>
);
}
const controller = new AbortController();
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError() {},
});
});
await serverAct(() => {
controller.abort('prerender abort');
});
const prerendered = await pendingResult;
prerendering = false;
const errors = [];
await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
{
signal: resumeController.signal,
onError(error) {
errors.push(error);
},
},
),
);
expect(errors).toEqual(['resume abort']);
});
it('can abort and suspend while replaying a prerendered tree', async () => {
const promise = new Promise(() => {});
let prerendering = true;
const resumeController = new AbortController();
function AbortDuringReplay({children}) {
if (!prerendering) {
resumeController.abort('resume abort');
React.use(promise);
}
return children;
}
function Wait() {
return React.use(promise);
}
function App() {
return (
<div>
<AbortDuringReplay>
<Suspense fallback="Loading...">
<Wait />
</Suspense>
</AbortDuringReplay>
</div>
);
}
const controller = new AbortController();
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError() {},
});
});
await serverAct(() => {
controller.abort('prerender abort');
});
const prerendered = await pendingResult;
prerendering = false;
const errors = [];
await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
{
signal: resumeController.signal,
onError(error) {
errors.push(error);
},
},
),
);
expect(errors).toEqual(['resume abort']);
});
it('can abort while rendering a resumed segment', async () => {
const promise = new Promise(() => {});
let prerendering = true;
const resumeController = new AbortController();
function Wait() {
if (prerendering) {
return React.use(promise);
}
resumeController.abort('resume abort');
return 'Hello';
}
function App() {
return (
<div>
<Suspense fallback="Loading...">
<Wait />
</Suspense>
</div>
);
}
const controller = new AbortController();
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError() {},
});
});
await serverAct(() => {
controller.abort('prerender abort');
});
const prerendered = await pendingResult;
prerendering = false;
const errors = [];
await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
{
signal: resumeController.signal,
onError(error) {
errors.push(error);
},
},
),
);
expect(errors).toEqual(['resume abort']);
});
[false, true].forEach(retryRootChild => {
it(`retries the whole root after a synchronous abort ${
retryRootChild ? 'in a retried task' : 'during the initial render'
}`, async () => {
const controller = new AbortController();
let prerendering = true;
let resolve;
const promise = new Promise(r => (resolve = r));
function Abort() {
if (prerendering) {
controller.abort('root abort');
}
return 'Child';
}
function Content() {
if (retryRootChild) {
React.use(promise);
}
return (
<section>
Before
<Abort />
<span>After</span>
</section>
);
}
function App() {
return (
<main>
<Content />
</main>
);
}
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError() {},
});
});
if (retryRootChild) {
await serverAct(() => resolve());
}
const prerendered = await pendingResult;
expect(prerendered.postponed).not.toBe(null);
expect(await readContent(prerendered.prelude)).toBe('');
prerendering = false;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await readIntoContainer(resumed);
expect(getVisibleChildren(container)).toEqual(
<main>
<section>{['Before', 'Child', <span>After</span>]}</section>
</main>,
);
});
});
it('retries the whole root after a resumed prerender aborts synchronously', async () => {
const controllers = [new AbortController(), new AbortController()];
let stage = 0;
let postponed;
function Abort() {
if (stage < 2) {
controllers[stage].abort('root abort');
}
return 'Child';
}
function App() {
return (
<main>
<section>
Before
<Abort />
<span>After</span>
</section>
</main>
);
}
for (let pass = 0; pass < 2; pass++) {
stage = pass;
const options = {
signal: controllers[pass].signal,
onError() {},
};
const result = await serverAct(() =>
pass === 0
? ReactDOMFizzStatic.prerender(<App />, options)
: ReactDOMFizzStatic.resumeAndPrerender(<App />, postponed, options),
);
expect(result.postponed).not.toBe(null);
expect(await readContent(result.prelude)).toBe('');
postponed = JSON.parse(JSON.stringify(result.postponed));
}
stage = 2;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(<App />, postponed),
);
await readIntoContainer(resumed);
expect(getVisibleChildren(container)).toEqual(
<main>
<section>{['Before', 'Child', <span>After</span>]}</section>
</main>,
);
});
it('preserves parent markup when a retried task aborts synchronously', async () => {
let resolve;
const promise = new Promise(r => (resolve = r));
let prerendering = true;
const controller = new AbortController();
function Abort() {
if (prerendering) {
controller.abort();
}
return 'World';
}
function Content() {
React.use(promise);
return (
<section>
<span>Hello</span>
<Abort />
</section>
);
}
function App() {
return (
<div>
<Suspense fallback="Loading...">
<Content />
</Suspense>
</div>
);
}
const errors = [];
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError(error) {
errors.push(error.message);
},
});
});
await serverAct(() => resolve());
const prerendered = await pendingResult;
expect(errors).toEqual(['This operation was aborted']);
await readIntoContainer(prerendered.prelude);
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
prerendering = false;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await readIntoContainer(resumed);
expect(getVisibleChildren(container)).toEqual(
<div>
<section>
<span>Hello</span>World
</section>
</div>,
);
});
it('resumes after one child suspends and another aborts the prerender', async () => {
const never = new Promise(() => {});
const controller = new AbortController();
let prerendering = true;
function Pending() {
if (prerendering) {
React.use(never);
}
return 'First';
}
function Abort() {
if (prerendering) {
controller.abort('prerender abort');
}
return 'Second';
}
function App() {
return (
<div>
<Suspense fallback="Loading...">
<Pending />
<Abort />
</Suspense>
</div>
);
}
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError() {},
});
});
const prerendered = await pendingResult;
expect(prerendered.postponed).not.toBe(null);
await readIntoContainer(prerendered.prelude);
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
prerendering = false;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await readIntoContainer(resumed);
expect(getVisibleChildren(container)).toEqual(
<div>{['First', 'Second']}</div>,
);
});
it('resumes an aborted prerender with children in a component', async () => {
const never = new Promise(() => {});
const controller = new AbortController();
let prerendering = true;
function Pending() {
if (prerendering) {
React.use(never);
}
return 'First';
}
function Abort() {
if (prerendering) {
controller.abort('prerender abort');
}
return 'Second';
}
function Content() {
return (
<>
<Pending />
<Abort />
</>
);
}
function App() {
return (
<div>
<Suspense fallback="Loading...">
<Content />
</Suspense>
</div>
);
}
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError() {},
});
});
const prerendered = await pendingResult;
expect(prerendered.postponed).not.toBe(null);
await readIntoContainer(prerendered.prelude);
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
prerendering = false;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await readIntoContainer(resumed);
expect(getVisibleChildren(container)).toEqual(
<div>{['First', 'Second']}</div>,
);
});
it('keeps static children but discards user code results after a synchronous abort', async () => {
const controller = new AbortController();
const Context = React.createContext(null);
let prerendering = true;
let hostCoercions = 0;
function Abort() {
if (prerendering) {
controller.abort('prerender abort');
}
return 'Abort';
}
function Later() {
return prerendering ? 'Too early' : 'Later';
}
function Content() {
const iterable = {
[Symbol.iterator]() {
let done = false;
return {
next() {
if (done) {
return {done: true};
}
done = true;
return {
done: false,
value: prerendering ? 'Iterable too early' : 'Iterable',
};
},
};
},
};
const thenable = {
then(resolve) {
resolve(prerendering ? 'Thenable too early' : 'Thenable');
},
};
const children = [
<Abort key="abort" />,
'Middle',
<span
key="host"
title={{
toString() {
hostCoercions++;
return 'host';
},
}}>
Host
</span>,
<Later key="later" />,
<Context.Consumer key="consumer">
{() => (prerendering ? 'Consumer too early' : 'Consumer')}
</Context.Consumer>,
iterable,
thenable,
];
return <section>{children}</section>;
}
function App() {
return (
<div>
<Suspense fallback="Loading...">
<Content />
</Suspense>
</div>
);
}
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError() {},
});
});
const prerendered = await pendingResult;
expect(hostCoercions).toBeGreaterThan(0);
expect(prerendered.postponed).not.toBe(null);
await readIntoContainer(prerendered.prelude);
expect(container.innerHTML.toLowerCase()).not.toContain('too early');
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
prerendering = false;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await readIntoContainer(resumed);
expect(getVisibleChildren(container)).toEqual(
<div>
<section>
{[
'Abort',
'Middle',
<span title="host">Host</span>,
'Later',
'Consumer',
'Iterable',
'Thenable',
]}
</section>
</div>,
);
});
it('resumes an aborting iterable returned by a component', async () => {
const controller = new AbortController();
let prerendering = true;
const iterable = {
[Symbol.iterator]() {
let done = false;
return {
next() {
if (done) {
return {done: true};
}
done = true;
if (prerendering) {
controller.abort('iterator abort');
}
return {
done: false,
value: prerendering ? (
<span key="child">Too early</span>
) : (
<span key="child">After</span>
),
};
},
};
},
};
function Child() {
return iterable;
}
function App() {
return (
<div>
<Suspense fallback="Loading...">
<section>
Before
<Child />
End
</section>
</Suspense>
</div>
);
}
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError() {},
});
});
const prerendered = await pendingResult;
expect(await readContent(prerendered.prelude)).not.toContain('Too early');
expect(prerendered.postponed).not.toBe(null);
prerendering = false;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
expect(await readContent(resumed)).toContain('After');
});
it('postpones an async iterable child when its iterator aborts synchronously', async () => {
const controller = new AbortController();
let prerendering = true;
const resolvedStep = step => ({
then(resolve) {
resolve(step);
},
});
const iterable = {
[Symbol.asyncIterator]() {
let done = false;
return {
next() {
if (done) {
return resolvedStep({done: true});
}
done = true;
if (prerendering) {
controller.abort('async iterator abort');
}
return resolvedStep({
done: false,
value: prerendering ? 'Too early' : 'After',
});
},
};
},
};
function App() {
return (
<div>
<Suspense fallback="Loading...">{iterable}</Suspense>
</div>
);
}
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError() {},
});
});
const prerendered = await pendingResult;
expect(await readContent(prerendered.prelude)).not.toContain('Too early');
expect(prerendered.postponed).not.toBe(null);
prerendering = false;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
expect(await readContent(resumed)).toContain('After');
});
it('postpones a thenable child when it aborts while resolving', async () => {
const controller = new AbortController();
let prerendering = true;
function App() {
const thenable = {
then(resolve) {
if (prerendering) {
controller.abort('thenable abort');
}
resolve(prerendering ? 'Too early' : 'After');
},
};
return (
<div>
<Suspense fallback="Loading...">{thenable}</Suspense>
</div>
);
}
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError() {},
});
});
const prerendered = await pendingResult;
expect(await readContent(prerendered.prelude)).not.toContain('Too early');
expect(prerendered.postponed).not.toBe(null);
prerendering = false;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
expect(await readContent(resumed)).toContain('After');
});
it('postpones a forwardRef when its render function aborts', async () => {
const controller = new AbortController();
let prerendering = true;
const Forwarded = React.forwardRef(function Forwarded() {
if (prerendering) {
controller.abort('forwardRef abort');
}
return prerendering ? 'Too early' : 'After';
});
function App() {
return (
<div>
<Suspense fallback="Loading...">
<Forwarded />
</Suspense>
</div>
);
}
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError() {},
});
});
const prerendered = await pendingResult;
expect(await readContent(prerendered.prelude)).not.toContain('Too early');
expect(prerendered.postponed).not.toBe(null);
prerendering = false;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
expect(await readContent(resumed)).toContain('After');
});
it('discards render-phase updates after an abort', async () => {
const controller = new AbortController();
let prerendering = true;
function Abort() {
const [value, setValue] = React.useState(0);
if (prerendering && value === 0) {
setValue(1);
} else if (prerendering && value === 1) {
controller.abort('render-phase abort');
setValue(2);
}
return 'After';
}
function App() {
return (
<div>
<Suspense fallback="Loading...">
<section>
Before
<Abort />
</section>
</Suspense>
</div>
);
}
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError() {},
});
});
const prerendered = await pendingResult;
expect(prerendered.postponed).not.toBe(null);
await readIntoContainer(prerendered.prelude);
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
prerendering = false;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await readIntoContainer(resumed);
expect(getVisibleChildren(container)).toEqual(
<div>
<section>{['Before', 'After']}</section>
</div>,
);
});
it('unwinds an abort in a nested Suspense fallback', async () => {
const never = new Promise(() => {});
const controller = new AbortController();
let prerendering = true;
let postAbortRenders = 0;
function Pending() {
if (prerendering) {
React.use(never);
}
return 'Content';
}
function AfterAbort() {
if (prerendering) {
postAbortRenders++;
}
return 'Inner fallback';
}
const FallbackAbort = React.forwardRef(function FallbackAbort() {
if (prerendering) {
controller.abort('fallback abort');
}
return <AfterAbort />;
});
function App() {
return (
<div>
<Suspense fallback="Outer fallback">
<Suspense fallback={<FallbackAbort />}>
<Pending />
</Suspense>
</Suspense>
</div>
);
}
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError() {},
});
});
const prerendered = await pendingResult;
expect(prerendered.postponed).not.toBe(null);
expect(postAbortRenders).toBe(0);
await readIntoContainer(prerendered.prelude);
expect(getVisibleChildren(container)).toEqual(<div>Outer fallback</div>);
prerendering = false;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await readIntoContainer(resumed);
expect(getVisibleChildren(container)).toEqual(<div>Content</div>);
});
it('can postpone the same boundary across repeated prerenders', async () => {
const never = new Promise(() => {});
const errors = [];
let ready = false;
let postponed;
function Wait() {
if (!ready) {
React.use(never);
}
return 'Hello';
}
function App() {
return (
<div>
<Suspense fallback="Loading...">
<Wait />
</Suspense>
</div>
);
}
for (let pass = 0; pass < 2; pass++) {
const controller = new AbortController();
const options = {
signal: controller.signal,
onError(error) {
errors.push(error);
},
};
let pendingResult;
await serverAct(() => {
pendingResult =
pass === 0
? ReactDOMFizzStatic.prerender(<App />, options)
: ReactDOMFizzStatic.resumeAndPrerender(
<App />,
postponed,
options,
);
});
await serverAct(() => controller.abort(`abort ${pass}`));
const result = await pendingResult;
expect(result.postponed).not.toBe(null);
await readIntoContainer(result.prelude);
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
postponed = JSON.parse(JSON.stringify(result.postponed));
}
expect(errors).toEqual(['abort 0', 'abort 1']);
ready = true;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(<App />, postponed, {
onError(error) {
errors.push(error);
},
}),
);
await readIntoContainer(resumed);
expect(errors).toEqual(['abort 0', 'abort 1']);
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
expect(container.querySelector('template')).toBe(null);
});
[false, true].forEach(completedAncestor => {
it(`preserves an earlier resumed boundary when a sibling aborts ${
completedAncestor ? 'under a completed Suspense ancestor' : 'at the root'
}`, async () => {
const never = new Promise(() => {});
const firstController = new AbortController();
const secondController = new AbortController();
let stage = 0;
function First() {
if (stage === 0) {
React.use(never);
}
return <article>First</article>;
}
function Second() {
if (stage === 0) {
React.use(never);
}
if (stage === 1) {
secondController.abort('second abort');
}
return <section>Second</section>;
}
function App() {
const content = (
<>
<Suspense fallback={<p>First loading</p>}>
<First />
</Suspense>
<Suspense fallback={<p>Second loading</p>}>
<Second />
</Suspense>
</>
);
return (
<main>
{completedAncestor ? (
<Suspense fallback="Outer loading">{content}</Suspense>
) : (
content
)}
</main>
);
}
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: firstController.signal,
onError() {},
});
});
await serverAct(() => firstController.abort('first abort'));
const initial = await pendingResult;
expect(initial.postponed).not.toBe(null);
await readIntoContainer(initial.prelude);
expect(getVisibleChildren(container)).toEqual(
<main>
<p>First loading</p>
<p>Second loading</p>
</main>,
);
stage = 1;
const second = await serverAct(() =>
ReactDOMFizzStatic.resumeAndPrerender(
<App />,
JSON.parse(JSON.stringify(initial.postponed)),
{
signal: secondController.signal,
onError() {},
},
),
);
expect(second.postponed).not.toBe(null);
await readIntoContainer(second.prelude);
expect(getVisibleChildren(container)).toEqual(
<main>
<article>First</article>
<p>Second loading</p>
</main>,
);
stage = 2;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(second.postponed)),
),
);
await readIntoContainer(resumed);
expect(getVisibleChildren(container)).toEqual(
<main>
<article>First</article>
<section>Second</section>
</main>,
);
expect(container.querySelector('template')).toBe(null);
});
});
it('can resume after a resumed prerender aborts again', async () => {
const never = new Promise(() => {});
const firstController = new AbortController();
const secondController = new AbortController();
let stage = 0;
const errors = [];
function Pending() {
if (stage < 2) {
React.use(never);
}
return 'First';
}
function AbortFirst() {
if (stage === 0) {
firstController.abort('first abort');
}
return 'Second';
}
function AbortSecond() {
if (stage === 1) {
secondController.abort('second abort');
}
return 'Third';
}
function App() {
return (
<div>
<Suspense fallback="Loading...">
<section>
Before
<Pending />
<AbortFirst />
<AbortSecond />
After
</section>
</Suspense>
</div>
);
}
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: firstController.signal,
onError(error) {
errors.push(error);
},
});
});
const first = await pendingResult;
expect(first.postponed).not.toBe(null);
expect(errors).toContain('first abort');
expect(errors.every(error => error === 'first abort')).toBe(true);
await readIntoContainer(first.prelude);
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
expect(container.querySelector('section').textContent).toBe('BeforeAfter');
stage = 1;
errors.length = 0;
let resumedResult;
let resumedResultSettled = false;
await serverAct(() => {
resumedResult = ReactDOMFizzStatic.resumeAndPrerender(
<App />,
JSON.parse(JSON.stringify(first.postponed)),
{
signal: secondController.signal,
onError(error) {
errors.push(error);
},
},
);
resumedResult.then(
() => (resumedResultSettled = true),
() => (resumedResultSettled = true),
);
});
expect(resumedResultSettled).toBe(true);
const second = await resumedResult;
expect(errors).toContain('second abort');
expect(errors.every(error => error === 'second abort')).toBe(true);
expect(second.postponed).not.toBe(null);
await readIntoContainer(second.prelude);
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
expect(container.querySelector('section').textContent).toBe(
'BeforeSecondAfter',
);
stage = 2;
errors.length = 0;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(second.postponed)),
{
onError(error) {
errors.push(error);
},
},
),
);
await readIntoContainer(resumed);
expect(errors).toEqual([]);
expect(getVisibleChildren(container)).toEqual(
<div>
<section>{['Before', 'First', 'Second', 'Third', 'After']}</section>
</div>,
);
expect(container.querySelector('template')).toBe(null);
});
['forwards', 'backwards', 'together'].forEach(revealOrder => {
[true, false].forEach(abortBeforeList => {
it(`preserves static ${revealOrder} SuspenseList rows when aborting ${
abortBeforeList ? 'before the list' : 'inside a row'
}`, async () => {
const controller = new AbortController();
let prerendering = true;
function Abort() {
if (prerendering) {
controller.abort('list abort');
}
return 'Aborted';
}
function Dynamic({text}) {
return text;
}
function Content() {
return (
<section>
{abortBeforeList ? <Abort /> : null}
<SuspenseList revealOrder={revealOrder}>
<span key="first" data-row="first">
First
<Dynamic text=" one" />
</span>
<span key="second" data-row="second">
Second{abortBeforeList ? null : <Abort />}
</span>
<span key="third" data-row="third">
Third
<Dynamic text=" three" />
</span>
</SuspenseList>
</section>
);
}
function App() {
return (
<div>
<Suspense fallback="Loading...">
<Content />
</Suspense>
</div>
);
}
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError() {},
});
});
const prerendered = await pendingResult;
expect(prerendered.postponed).not.toBe(null);
await readIntoContainer(prerendered.prelude);
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
expect(container.textContent).not.toContain('Aborted');
expect(container.querySelector('[data-row="first"]').textContent).toBe(
abortBeforeList ? 'First' : 'First one',
);
expect(container.querySelector('[data-row="second"]').textContent).toBe(
'Second',
);
expect(container.querySelector('[data-row="third"]').textContent).toBe(
'Third',
);
prerendering = false;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await readIntoContainer(resumed);
const expectedRows = [
<span data-row="first">{['First', ' one']}</span>,
<span data-row="second">
{abortBeforeList ? 'Second' : ['Second', 'Aborted']}
</span>,
<span data-row="third">{['Third', ' three']}</span>,
];
if (revealOrder === 'backwards') {
expectedRows.reverse();
}
if (abortBeforeList) {
expectedRows.unshift('Aborted');
}
expect(getVisibleChildren(container)).toEqual(
<div>
<section>{expectedRows}</section>
</div>,
);
});
});
});
['forwards', 'backwards', 'together'].forEach(revealOrder => {
it(`settles an abort in a ${revealOrder} SuspenseList row`, async () => {
let resolveGate;
const gate = new Promise(resolve => (resolveGate = resolve));
const controller = new AbortController();
let prerendering = true;
function Abort() {
if (prerendering) {
controller.abort('row abort');
}
return 'Fallback';
}
function Content() {
React.use(gate);
return (
<SuspenseList revealOrder={revealOrder}>
<Suspense key="one" fallback={<Abort />}>
First
</Suspense>
<Suspense key="two" fallback="SecondFallback">
Second
</Suspense>
</SuspenseList>
);
}
function App() {
return (
<div>
<Suspense fallback="Loading...">
<Content />
</Suspense>
</div>
);
}
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError() {},
});
});
await serverAct(() => resolveGate());
const prerendered = await pendingResult;
expect(prerendered.postponed).not.toBe(null);
prerendering = false;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await readIntoContainer(prerendered.prelude);
await readIntoContainer(resumed);
expect(container.querySelector('div').textContent).toBe(
revealOrder === 'backwards' ? 'SecondFirst' : 'FirstSecond',
);
});
});
it('resumes a retried array child after its child suspends and another aborts', async () => {
const never = new Promise(() => {});
let resolveGate;
const gate = new Promise(resolve => (resolveGate = resolve));
const controller = new AbortController();
let prerendering = true;
const errors = [];
function Pending() {
if (prerendering) {
React.use(never);
}
return 'First';
}
function OutsidePending() {
if (prerendering) {
React.use(never);
}
return 'Outside';
}
function Outside() {
return <OutsidePending />;
}
function Abort() {
if (prerendering) {
controller.abort('array child abort');
}
return 'Second';
}
function Gate() {
React.use(gate);
return (
<>
<Pending />
<Abort />
</>
);
}
function App() {
return (
<div id="result">
<Suspense fallback="Loading...">
<span>Prefix</span>
<Outside />
<Gate key="gate" />
<span>Suffix</span>
</Suspense>
</div>
);
}
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError(error) {
errors.push(error);
},
});
});
await serverAct(() => resolveGate());
const prerendered = await pendingResult;
expect(prerendered.postponed).not.toBe(null);
await readIntoContainer(prerendered.prelude);
expect(getVisibleChildren(container)).toEqual(
<div id="result">Loading...</div>,
);
prerendering = false;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
{
onError(error) {
errors.push(error);
},
},
),
);
await readIntoContainer(resumed);
expect(container.querySelector('#result').textContent).toBe(
'PrefixOutsideFirstSecondSuffix',
);
expect(errors.length).toBeGreaterThan(0);
expect(errors.every(error => error === 'array child abort')).toBe(true);
});
it('can prerender a preamble', async () => {
const errors = [];
let resolveA;
const promiseA = new Promise(r => (resolveA = r));
let resolveB;
const promiseB = new Promise(r => (resolveB = r));
async function ComponentA() {
await promiseA;
return (
<Suspense fallback="Loading B">
<ComponentB />
</Suspense>
);
}
async function ComponentB() {
await promiseB;
return 'Hello';
}
function App() {
return (
<Suspense>
<html data-x="">
<body data-x="">
<Suspense fallback="Loading A">
<ComponentA />
</Suspense>
</body>
</html>
</Suspense>
);
}
const controller = new AbortController();
let pendingResult;
await serverAct(async () => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
});
});
await serverAct(() => {
controller.abort();
});
const prerendered = await pendingResult;
const postponedState = JSON.stringify(prerendered.postponed);
await readIntoNewDocument(prerendered.prelude);
expect(getVisibleChildren(document)).toEqual(
<html data-x="">
<head />
<body data-x="">Loading A</body>
</html>,
);
if (!ArrayBuffer.prototype.hasOwnProperty('detached')) {
assertConsoleErrorDev([
'React encountered an empty buffer when writing in an environment that does not support ArrayBuffer.prototype.detached. ' +
'This could be a security issue if the buffer was not actually empty. ' +
'Please use a modern browser that supports ArrayBuffer.prototype.detached.',
]);
}
await resolveA();
expect(prerendered.postponed).not.toBe(null);
const controller2 = new AbortController();
await serverAct(async () => {
pendingResult = ReactDOMFizzStatic.resumeAndPrerender(
<App />,
JSON.parse(postponedState),
{
signal: controller2.signal,
onError(x) {
errors.push(x.message);
},
},
);
});
await serverAct(() => {
controller2.abort();
});
const prerendered2 = await pendingResult;
const postponedState2 = JSON.stringify(prerendered2.postponed);
await readIntoCurrentDocument(prerendered2.prelude);
expect(getVisibleChildren(document)).toEqual(
<html data-x="">
<head />
<body data-x="">Loading B</body>
</html>,
);
await resolveB();
const dynamic = await serverAct(() =>
ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState2)),
);
await readIntoCurrentDocument(dynamic);
expect(getVisibleChildren(document)).toEqual(
<html data-x="">
<head />
<body data-x="">Hello</body>
</html>,
);
});
it('can suspend inside <head> tag', async () => {
const promise = new Promise(() => {});
function App() {
return (
<html>
<head>
<Suspense fallback={<meta itemProp="" content="fallback" />}>
<Metadata />
</Suspense>
</head>
<body>
<div>hello</div>
</body>
</html>
);
}
function Metadata() {
React.use(promise);
return <meta itemProp="" content="primary" />;
}
const controller = new AbortController();
let pendingResult;
const errors = [];
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError: e => {
errors.push(e.message);
},
});
});
await serverAct(() => {
controller.abort(new Error('boom'));
});
const prerendered = await pendingResult;
await readIntoNewDocument(prerendered.prelude);
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<meta itemprop="" content="fallback" />
</head>
<body>
<div>hello</div>
</body>
</html>,
);
expect(errors).toEqual(['boom']);
if (!ArrayBuffer.prototype.hasOwnProperty('detached')) {
assertConsoleErrorDev([
'React encountered an empty buffer when writing in an environment that does not support ArrayBuffer.prototype.detached. ' +
'This could be a security issue if the buffer was not actually empty. ' +
'Please use a modern browser that supports ArrayBuffer.prototype.detached.',
]);
}
});
it('will render fallback Document when erroring a boundary above the body', async () => {
let isPrerendering = true;
const promise = new Promise(() => {});
function Boom() {
if (isPrerendering) {
React.use(promise);
}
throw new Error('Boom!');
}
function App() {
return (
<Suspense
fallback={
<html data-error-html="">
<body data-error-body="">
<span>hello error</span>
</body>
</html>
}>
<html data-content-html="">
<body data-content-body="">
<Boom />
<span>hello world</span>
</body>
</html>
</Suspense>
);
}
const controller = new AbortController();
let pendingResult;
const errors = [];
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError: e => {
errors.push(e.message);
},
});
});
await serverAct(() => {
controller.abort();
});
const prerendered = await pendingResult;
expect(errors).toEqual(['This operation was aborted']);
const content = await readContent(prerendered.prelude);
expect(content).toBe('');
isPrerendering = false;
const postponedState = JSON.stringify(prerendered.postponed);
const resumeErrors = [];
const dynamic = await serverAct(() =>
ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState), {
onError: e => {
resumeErrors.push(e.message);
},
}),
);
expect(resumeErrors).toEqual(['Boom!']);
await readIntoNewDocument(dynamic);
expect(getVisibleChildren(document)).toEqual(
<html data-error-html="">
<head />
<body data-error-body="">
<span>hello error</span>
</body>
</html>,
);
if (!ArrayBuffer.prototype.hasOwnProperty('detached')) {
assertConsoleErrorDev([
'React encountered an empty buffer when writing in an environment that does not support ArrayBuffer.prototype.detached. ' +
'This could be a security issue if the buffer was not actually empty. ' +
'Please use a modern browser that supports ArrayBuffer.prototype.detached.',
]);
}
});
it('reveals a resumed boundary even when the shell outlined a completed boundary', async () => {
let prerendering = true;
const shellText = 'a'.repeat(800);
const resumeText = 'b'.repeat(800);
function ShellBoundary() {
return <div>{shellText}</div>;
}
function Hole() {
if (prerendering) {
return React.use(theInfinitePromise);
}
return (
<Suspense fallback="LoadingC">
<div>{resumeText}</div>
</Suspense>
);
}
function App() {
return (
<div>
<Suspense fallback="LoadingA">
<ShellBoundary />
</Suspense>
<Suspense fallback="LoadingB">
<Hole />
</Suspense>
</div>
);
}
const controller = new AbortController();
let pendingResult;
await serverAct(() => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
progressiveChunkSize: 100,
onError() {},
});
});
await serverAct(() => controller.abort());
const prerendered = await pendingResult;
expect(prerendered.postponed).not.toBe(null);
const shellHTML = await readContent(prerendered.prelude);
prerendering = false;
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
{onError() {}},
),
);
const resumeHTML = await readContent(resumed);
const temp = document.createElement('div');
temp.innerHTML = shellHTML + resumeHTML;
await insertNodesAndExecuteScripts(temp, container, null);
jest.runAllTimers();
expect(getVisibleChildren(container)).toEqual(
<div>
<div>{shellText}</div>
<div>{resumeText}</div>
</div>,
);
});
it('can omit a preamble with an empty shell if no preamble is ready when prerendering finishes', async () => {
const errors = [];
let resolveA;
const promiseA = new Promise(r => (resolveA = r));
let resolveB;
const promiseB = new Promise(r => (resolveB = r));
async function ComponentA() {
await promiseA;
return (
<Suspense fallback="Loading B">
<ComponentB />
</Suspense>
);
}
async function ComponentB() {
await promiseB;
return 'Hello';
}
function App() {
return (
<Suspense>
<html data-x="">
<body data-x="">
<ComponentA />
</body>
</html>
</Suspense>
);
}
const controller = new AbortController();
let pendingResult;
await serverAct(async () => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
});
});
await serverAct(() => {
controller.abort();
});
const prerendered = await pendingResult;
const postponedState = JSON.stringify(prerendered.postponed);
const content = await readContent(prerendered.prelude);
expect(content).toBe('');
await resolveA();
expect(prerendered.postponed).not.toBe(null);
const controller2 = new AbortController();
await serverAct(async () => {
pendingResult = ReactDOMFizzStatic.resumeAndPrerender(
<App />,
JSON.parse(postponedState),
{
signal: controller2.signal,
onError(x) {
errors.push(x.message);
},
},
);
});
await serverAct(() => {
controller2.abort();
});
const prerendered2 = await pendingResult;
const postponedState2 = JSON.stringify(prerendered2.postponed);
await readIntoNewDocument(prerendered2.prelude);
expect(getVisibleChildren(document)).toEqual(
<html data-x="">
<head />
<body data-x="">Loading B</body>
</html>,
);
if (!ArrayBuffer.prototype.hasOwnProperty('detached')) {
assertConsoleErrorDev([
'React encountered an empty buffer when writing in an environment that does not support ArrayBuffer.prototype.detached. ' +
'This could be a security issue if the buffer was not actually empty. ' +
'Please use a modern browser that supports ArrayBuffer.prototype.detached.',
]);
}
await resolveB();
const dynamic = await serverAct(() =>
ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState2)),
);
await readIntoCurrentDocument(dynamic);
expect(getVisibleChildren(document)).toEqual(
<html data-x="">
<head />
<body data-x="">Hello</body>
</html>,
);
});
it('can resume a partially prerendered SuspenseList', async () => {
const errors = [];
let resolveA;
const promiseA = new Promise(r => (resolveA = r));
let resolveB;
const promiseB = new Promise(r => (resolveB = r));
async function ComponentA() {
await promiseA;
return 'A';
}
async function ComponentB() {
await promiseB;
return 'B';
}
function App() {
return (
<div>
<SuspenseList revealOrder="forwards" tail="visible">
<Suspense fallback="Loading A">
<ComponentA />
</Suspense>
<Suspense fallback="Loading B">
<ComponentB />
</Suspense>
<Suspense fallback="Loading C">C</Suspense>
<Suspense fallback="Loading D">D</Suspense>
</SuspenseList>
</div>
);
}
const controller = new AbortController();
const pendingResult = serverAct(() =>
ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
}),
);
await serverAct(() => {
controller.abort();
});
const prerendered = await pendingResult;
const postponedState = JSON.stringify(prerendered.postponed);
await readIntoContainer(prerendered.prelude);
expect(getVisibleChildren(container)).toEqual(
<div>
{'Loading A'}
{'Loading B'}
{'Loading C'}
{'Loading D'}
</div>,
);
expect(prerendered.postponed).not.toBe(null);
await resolveA();
await resolveB();
const dynamic = await serverAct(() =>
ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState)),
);
await readIntoContainer(dynamic);
expect(getVisibleChildren(container)).toEqual(
<div>
{'A'}
{'B'}
{'C'}
{'D'}
</div>,
);
});
it('can resume an optimistic keyed slot', async () => {
const errors = [];
let resolve;
const promise = new Promise(r => (resolve = r));
async function Component() {
await promise;
return 'Hi';
}
if (React.optimisticKey === undefined) {
throw new Error('optimisticKey missing');
}
function App() {
return (
<div>
<Suspense fallback="Loading">
<Component key={React.optimisticKey} />
</Suspense>
</div>
);
}
const controller = new AbortController();
const pendingResult = serverAct(() =>
ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
}),
);
await serverAct(() => {
controller.abort();
});
const prerendered = await pendingResult;
const postponedState = JSON.stringify(prerendered.postponed);
await readIntoContainer(prerendered.prelude);
expect(getVisibleChildren(container)).toEqual(<div>Loading</div>);
expect(prerendered.postponed).not.toBe(null);
await resolve();
const dynamic = await serverAct(() =>
ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState)),
);
await readIntoContainer(dynamic);
expect(getVisibleChildren(container)).toEqual(<div>Hi</div>);
});
describe('abort signal lifetime', () => {
function trackAbortListenerLifetimes(signal) {
const lifetimes = [];
const add = signal.addEventListener.bind(signal);
signal.addEventListener = (type, listener, options) => {
if (type === 'abort') {
lifetimes.push(options.signal);
}
return add(type, listener, options);
};
return lifetimes;
}
it('detaches the listener when a prerender completes', async () => {
const controller = new AbortController();
const lifetimes = trackAbortListenerLifetimes(controller.signal);
const result = await serverAct(() =>
ReactDOMFizzStatic.prerender(<div>hello world</div>, {
signal: controller.signal,
}),
);
expect(lifetimes).toHaveLength(1);
expect(lifetimes[0].aborted).toBe(false);
await readContent(result.prelude);
expect(lifetimes[0].aborted).toBe(true);
});
it('detaches the listener when a render completes', async () => {
const controller = new AbortController();
const lifetimes = trackAbortListenerLifetimes(controller.signal);
const stream = await serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(<div>hello world</div>, {
signal: controller.signal,
}),
);
expect(lifetimes).toHaveLength(1);
expect(lifetimes[0].aborted).toBe(false);
await readContent(stream);
expect(lifetimes[0].aborted).toBe(true);
});
it('detaches the listener when the signal aborts mid-render', async () => {
let hasLoaded = false;
let resolve;
const promise = new Promise(r => (resolve = r));
function Wait() {
if (!hasLoaded) {
throw promise;
}
return 'Done';
}
const controller = new AbortController();
const lifetimes = trackAbortListenerLifetimes(controller.signal);
const resultPromise = ReactDOMFizzStatic.prerender(
<div>
<Suspense fallback="Loading">
<Wait />
</Suspense>
</div>,
{signal: controller.signal, onError() {}},
);
await jest.runAllTimers();
expect(lifetimes).toHaveLength(1);
expect(lifetimes[0].aborted).toBe(false);
controller.abort();
hasLoaded = true;
resolve();
await serverAct(() => resultPromise);
expect(lifetimes[0].aborted).toBe(true);
});
it('detaches the listener when the stream is cancelled', async () => {
let hasLoaded = false;
let resolve;
const promise = new Promise(r => (resolve = r));
function Wait() {
if (!hasLoaded) {
throw promise;
}
return 'Done';
}
const controller = new AbortController();
const lifetimes = trackAbortListenerLifetimes(controller.signal);
const stream = await serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(
<div>
<Suspense fallback="Loading">
<Wait />
</Suspense>
</div>,
{signal: controller.signal, onError() {}},
),
);
expect(lifetimes).toHaveLength(1);
expect(lifetimes[0].aborted).toBe(false);
await serverAct(() => stream.cancel());
hasLoaded = true;
resolve();
expect(lifetimes[0].aborted).toBe(true);
});
it('detaches the listener when the shell errors', async () => {
function Boom() {
throw new Error('boom');
}
const controller = new AbortController();
const lifetimes = trackAbortListenerLifetimes(controller.signal);
await expect(
serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(<Boom />, {
signal: controller.signal,
onError() {},
}),
),
).rejects.toThrow('boom');
expect(lifetimes).toHaveLength(1);
expect(lifetimes[0].aborted).toBe(true);
});
it('constructs no abort controller when no signal is passed', async () => {
const RealAbortController = globalThis.AbortController;
let constructed = 0;
globalThis.AbortController = class extends RealAbortController {
constructor() {
super();
constructed++;
}
};
try {
const stream = await serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(<div>hello world</div>),
);
await readContent(stream);
} finally {
globalThis.AbortController = RealAbortController;
}
expect(constructed).toBe(0);
});
it('attaches no listener when the signal is already aborted', async () => {
const controller = new AbortController();
controller.abort();
const lifetimes = trackAbortListenerLifetimes(controller.signal);
await serverAct(() =>
ReactDOMFizzStatic.prerender(<div>hello world</div>, {
signal: controller.signal,
onError() {},
}),
);
expect(lifetimes).toHaveLength(0);
});
});
});