'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;
describe('ReactDOMFizzStaticBrowser', () => {
beforeEach(() => {
jest.resetModules();
JSDOM = require('jsdom').JSDOM;
window.setTimeout = setTimeout;
window.requestAnimationFrame = setTimeout;
patchMessageChannel();
serverAct = require('internal-test-utils').serverAct;
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);
},
},
);
});
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 reject if aborting before the shell is complete and enableHalt is disabled', 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');
controller.abort(theReason);
let caughtError = null;
try {
await promise;
} catch (error) {
caughtError = error;
}
expect(caughtError).toBe(theReason);
expect(errors).toEqual(['aborted for reasons']);
});
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');
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);
},
},
),
);
if (gate(flags => flags.enableHalt)) {
const {prelude} = await streamPromise;
const content = await readContent(prelude);
expect(errors).toEqual(['This operation was aborted']);
expect(content).toBe('');
} else {
let caughtError = null;
try {
await streamPromise;
} catch (error) {
caughtError = error;
}
expect(caughtError.message).toBe('This operation was aborted');
expect(errors).toEqual(['This operation was aborted']);
}
});
it('should reject if passing an already aborted signal and enableHalt is disabled', 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 caughtError = null;
try {
await promise;
} catch (error) {
caughtError = error;
}
expect(caughtError).toBe(theReason);
expect(errors).toEqual(['aborted for reasons']);
});
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';
},
});
});
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';
},
});
});
controller.abort(new Error('uh oh'));
await resultPromise;
expect(errors).toEqual(['uh oh', 'uh oh']);
});
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(
gate(flags => flags.enableHalt) ? null : undefined,
);
expect(errors).toEqual(['bad onHeaders']);
await readIntoContainer(prerendered.prelude);
expect(getVisibleChildren(container)).toEqual(<div>hello</div>);
});
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);
},
});
});
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);
},
},
);
});
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 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);
},
});
});
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>,
);
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);
},
},
);
});
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);
},
});
});
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']);
});
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);
},
});
});
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>,
);
});
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);
},
});
});
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);
},
},
);
});
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>,
);
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>);
});
});