/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
* @jest-environment node
*/
'use strict';
let Stream;
let React;
let ReactDOMFizzServer;
let Suspense;
describe('ReactDOMFizzServerNode', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactDOMFizzServer = require('react-dom/server');
Stream = require('stream');
Suspense = React.Suspense;
});
function getTestWritable() {
const writable = new Stream.PassThrough();
writable.setEncoding('utf8');
const output = {result: '', error: undefined};
writable.on('data', chunk => {
output.result += chunk;
});
writable.on('error', error => {
output.error = error;
});
const completed = new Promise(resolve => {
writable.on('finish', () => {
resolve();
});
writable.on('error', () => {
resolve();
});
});
return {writable, completed, output};
}
const theError = new Error('This is an error');
function Throw() {
throw theError;
}
const theInfinitePromise = new Promise(() => {});
function InfiniteSuspend() {
throw theInfinitePromise;
}
it('should call renderToPipeableStream', () => {
const {writable, output} = getTestWritable();
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>hello world</div>,
);
pipe(writable);
jest.runAllTimers();
expect(output.result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
});
it('should emit DOCTYPE at the root of the document', () => {
const {writable, output} = getTestWritable();
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<html>
<body>hello world</body>
</html>,
);
pipe(writable);
jest.runAllTimers();
if (gate(flags => flags.enableFloat)) {
// with Float, we emit empty heads if they are elided when rendering <html>
expect(output.result).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
);
} else {
expect(output.result).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><body>hello world</body></html>"`,
);
}
});
it('should emit bootstrap script src at the end', () => {
const {writable, output} = getTestWritable();
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>hello world</div>,
{
bootstrapScriptContent: 'INIT();',
bootstrapScripts: ['init.js'],
bootstrapModules: ['init.mjs'],
},
);
pipe(writable);
jest.runAllTimers();
expect(output.result).toMatchInlineSnapshot(
`"<link rel="preload" as="script" fetchPriority="low" href="init.js"/><link rel="modulepreload" fetchPriority="low" href="init.mjs"/><div>hello world</div><script>INIT();</script><script src="init.js" async=""></script><script type="module" src="init.mjs" async=""></script>"`,
);
});
it('should start writing after pipe', () => {
const {writable, output} = getTestWritable();
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>hello world</div>,
);
jest.runAllTimers();
// First we write our header.
output.result +=
'<!doctype html><html><head><title>test</title><head><body>';
// Then React starts writing.
pipe(writable);
expect(output.result).toMatchInlineSnapshot(
`"<!doctype html><html><head><title>test</title><head><body><div>hello world</div>"`,
);
});
it('emits all HTML as one unit if we wait until the end to start', async () => {
let hasLoaded = false;
let resolve;
const promise = new Promise(r => (resolve = r));
function Wait() {
if (!hasLoaded) {
throw promise;
}
return 'Done';
}
let isCompleteCalls = 0;
const {writable, output} = getTestWritable();
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>
<Suspense fallback="Loading">
<Wait />
</Suspense>
</div>,
{
onAllReady() {
isCompleteCalls++;
},
},
);
await jest.runAllTimers();
expect(output.result).toBe('');
expect(isCompleteCalls).toBe(0);
// Resolve the loading.
hasLoaded = true;
await resolve();
await jest.runAllTimers();
expect(output.result).toBe('');
expect(isCompleteCalls).toBe(1);
// First we write our header.
output.result +=
'<!doctype html><html><head><title>test</title><head><body>';
// Then React starts writing.
pipe(writable);
expect(output.result).toMatchInlineSnapshot(
`"<!doctype html><html><head><title>test</title><head><body><div><!--$-->Done<!-- --><!--/$--></div>"`,
);
});
it('should error the stream when an error is thrown at the root', async () => {
const reportedErrors = [];
const reportedShellErrors = [];
const {writable, output, completed} = getTestWritable();
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>
<Throw />
</div>,
{
onError(x) {
reportedErrors.push(x);
},
onShellError(x) {
reportedShellErrors.push(x);
},
},
);
// The stream is errored once we start writing.
pipe(writable);
await completed;
expect(output.error).toBe(theError);
expect(output.result).toBe('');
// This type of error is reported to the error callback too.
expect(reportedErrors).toEqual([theError]);
expect(reportedShellErrors).toEqual([theError]);
});
it('should error the stream when an error is thrown inside a fallback', async () => {
const reportedErrors = [];
const reportedShellErrors = [];
const {writable, output, completed} = getTestWritable();
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>
<Suspense fallback={<Throw />}>
<InfiniteSuspend />
</Suspense>
</div>,
{
onError(x) {
reportedErrors.push(x.message);
},
onShellError(x) {
reportedShellErrors.push(x);
},
},
);
pipe(writable);
await completed;
expect(output.error).toBe(theError);
expect(output.result).toBe('');
expect(reportedErrors).toEqual([
theError.message,
'The destination stream errored while writing data.',
]);
expect(reportedShellErrors).toEqual([theError]);
});
it('should not error the stream when an error is thrown inside suspense boundary', async () => {
const reportedErrors = [];
const reportedShellErrors = [];
const {writable, output, completed} = getTestWritable();
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>
<Suspense fallback={<div>Loading</div>}>
<Throw />
</Suspense>
</div>,
{
onError(x) {
reportedErrors.push(x);
},
onShellError(x) {
reportedShellErrors.push(x);
},
},
);
pipe(writable);
await completed;
expect(output.error).toBe(undefined);
expect(output.result).toContain('Loading');
// While no error is reported to the stream, the error is reported to the callback.
expect(reportedErrors).toEqual([theError]);
expect(reportedShellErrors).toEqual([]);
});
it('should not attempt to render the fallback if the main content completes first', async () => {
const {writable, output, completed} = getTestWritable();
let renderedFallback = false;
function Fallback() {
renderedFallback = true;
return 'Loading...';
}
function Content() {
return 'Hi';
}
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<Suspense fallback={<Fallback />}>
<Content />
</Suspense>,
);
pipe(writable);
await completed;
expect(output.result).toContain('Hi');
expect(output.result).not.toContain('Loading');
expect(renderedFallback).toBe(false);
});
it('should be able to complete by aborting even if the promise never resolves', async () => {
let isCompleteCalls = 0;
const errors = [];
const {writable, output, completed} = getTestWritable();
const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(
<div>
<Suspense fallback={<div>Loading</div>}>
<InfiniteSuspend />
</Suspense>
</div>,
{
onError(x) {
errors.push(x.message);
},
onAllReady() {
isCompleteCalls++;
},
},
);
pipe(writable);
jest.runAllTimers();
expect(output.result).toContain('Loading');
expect(isCompleteCalls).toBe(0);
abort(new Error('uh oh'));
await completed;
expect(errors).toEqual(['uh oh']);
expect(output.error).toBe(undefined);
expect(output.result).toContain('Loading');
expect(isCompleteCalls).toBe(1);
});
it('should fail the shell if you abort before work has begun', async () => {
let isCompleteCalls = 0;
const errors = [];
const shellErrors = [];
const {writable, output, completed} = getTestWritable();
const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(
<div>
<Suspense fallback={<div>Loading</div>}>
<InfiniteSuspend />
</Suspense>
</div>,
{
onError(x) {
errors.push(x.message);
},
onShellError(x) {
shellErrors.push(x.message);
},
onAllReady() {
isCompleteCalls++;
},
},
);
pipe(writable);
// Currently we delay work so if we abort, we abort the remaining CPU
// work as well.
// Abort before running the timers that perform the work
const theReason = new Error('uh oh');
abort(theReason);
jest.runAllTimers();
await completed;
expect(errors).toEqual(['uh oh']);
expect(shellErrors).toEqual(['uh oh']);
expect(output.error).toBe(theReason);
expect(output.result).toBe('');
expect(isCompleteCalls).toBe(0);
});
it('should be able to complete by abort when the fallback is also suspended', async () => {
let isCompleteCalls = 0;
const errors = [];
const {writable, output, completed} = getTestWritable();
const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(
<div>
<Suspense fallback="Loading">
<Suspense fallback={<InfiniteSuspend />}>
<InfiniteSuspend />
</Suspense>
</Suspense>
</div>,
{
onError(x) {
errors.push(x.message);
},
onAllReady() {
isCompleteCalls++;
},
},
);
pipe(writable);
jest.runAllTimers();
expect(output.result).toContain('Loading');
expect(isCompleteCalls).toBe(0);
abort();
await completed;
expect(errors).toEqual([
// There are two boundaries that abort
'The render was aborted by the server without a reason.',
'The render was aborted by the server without a reason.',
]);
expect(output.error).toBe(undefined);
expect(output.result).toContain('Loading');
expect(isCompleteCalls).toBe(1);
});
it('should be able to get context value when promise resolves', async () => {
class DelayClient {
get() {
if (this.resolved) return this.resolved;
if (this.pending) return this.pending;
return (this.pending = new Promise(resolve => {
setTimeout(() => {
delete this.pending;
this.resolved = 'OK';
resolve();
}, 500);
}));
}
}
const DelayContext = React.createContext(undefined);
const Component = () => {
const client = React.useContext(DelayContext);
if (!client) {
return 'context not found.';
}
const result = client.get();
if (typeof result === 'string') {
return result;
}
throw result;
};
const client = new DelayClient();
const {writable, output, completed} = getTestWritable();
ReactDOMFizzServer.renderToPipeableStream(
<DelayContext.Provider value={client}>
<Suspense fallback="loading">
<Component />
</Suspense>
</DelayContext.Provider>,
).pipe(writable);
jest.runAllTimers();
expect(output.error).toBe(undefined);
expect(output.result).toContain('loading');
await completed;
expect(output.error).toBe(undefined);
expect(output.result).not.toContain('context never found');
expect(output.result).toContain('OK');
});
it('should be able to get context value when calls renderToPipeableStream twice at the same time', async () => {
class DelayClient {
get() {
if (this.resolved) return this.resolved;
if (this.pending) return this.pending;
return (this.pending = new Promise(resolve => {
setTimeout(() => {
delete this.pending;
this.resolved = 'OK';
resolve();
}, 500);
}));
}
}
const DelayContext = React.createContext(undefined);
const Component = () => {
const client = React.useContext(DelayContext);
if (!client) {
return 'context never found';
}
const result = client.get();
if (typeof result === 'string') {
return result;
}
throw result;
};
const client0 = new DelayClient();
const {
writable: writable0,
output: output0,
completed: completed0,
} = getTestWritable();
ReactDOMFizzServer.renderToPipeableStream(
<DelayContext.Provider value={client0}>
<Suspense fallback="loading">
<Component />
</Suspense>
</DelayContext.Provider>,
).pipe(writable0);
const client1 = new DelayClient();
const {
writable: writable1,
output: output1,
completed: completed1,
} = getTestWritable();
ReactDOMFizzServer.renderToPipeableStream(
<DelayContext.Provider value={client1}>
<Suspense fallback="loading">
<Component />
</Suspense>
</DelayContext.Provider>,
).pipe(writable1);
jest.runAllTimers();
expect(output0.error).toBe(undefined);
expect(output0.result).toContain('loading');
expect(output1.error).toBe(undefined);
expect(output1.result).toContain('loading');
await Promise.all([completed0, completed1]);
expect(output0.error).toBe(undefined);
expect(output0.result).not.toContain('context never found');
expect(output0.result).toContain('OK');
expect(output1.error).toBe(undefined);
expect(output1.result).not.toContain('context never found');
expect(output1.result).toContain('OK');
});
it('should be able to pop context after suspending', async () => {
class DelayClient {
get() {
if (this.resolved) return this.resolved;
if (this.pending) return this.pending;
return (this.pending = new Promise(resolve => {
setTimeout(() => {
delete this.pending;
this.resolved = 'OK';
resolve();
}, 500);
}));
}
}
const DelayContext = React.createContext(undefined);
const Component = () => {
const client = React.useContext(DelayContext);
if (!client) {
return 'context not found.';
}
const result = client.get();
if (typeof result === 'string') {
return result;
}
throw result;
};
const client = new DelayClient();
const {writable, output, completed} = getTestWritable();
ReactDOMFizzServer.renderToPipeableStream(
<>
<DelayContext.Provider value={client}>
<Suspense fallback="loading">
<Component />
</Suspense>
</DelayContext.Provider>
<DelayContext.Provider value={client}>
<Suspense fallback="loading">
<Component />
</Suspense>
</DelayContext.Provider>
</>,
).pipe(writable);
jest.runAllTimers();
expect(output.error).toBe(undefined);
expect(output.result).toContain('loading');
await completed;
expect(output.error).toBe(undefined);
expect(output.result).not.toContain('context never found');
expect(output.result).toContain('OK');
});
it('should not continue rendering after the writable ends unexpectedly', async () => {
let hasLoaded = false;
let resolve;
let isComplete = false;
let rendered = false;
const promise = new Promise(r => (resolve = r));
function Wait() {
if (!hasLoaded) {
throw promise;
}
rendered = true;
return 'Done';
}
const errors = [];
const {writable, completed} = getTestWritable();
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>
<Suspense fallback={<div>Loading</div>}>
<Wait />
</Suspense>
</div>,
{
onError(x) {
errors.push(x.message);
},
onAllReady() {
isComplete = true;
},
},
);
pipe(writable);
expect(rendered).toBe(false);
expect(isComplete).toBe(false);
writable.end();
await jest.runAllTimers();
hasLoaded = true;
resolve();
await completed;
expect(errors).toEqual([
'The destination stream errored while writing data.',
]);
expect(rendered).toBe(false);
expect(isComplete).toBe(true);
});
it('should encode multibyte characters correctly without nulls (#24985)', () => {
const {writable, output} = getTestWritable();
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>{Array(700).fill('ののの')}</div>,
);
pipe(writable);
jest.runAllTimers();
expect(output.result.indexOf('\u0000')).toBe(-1);
expect(output.result).toEqual(
'<div>' + Array(700).fill('ののの').join('<!-- -->') + '</div>',
);
});
});