'use strict';
const stream = require('stream');
const shouldIgnoreConsoleError = require('internal-test-utils/shouldIgnoreConsoleError');
module.exports = function (initModules) {
let ReactDOM;
let ReactDOMClient;
let ReactDOMServer;
let act;
let ReactFeatureFlags;
function resetModules() {
({ReactDOM, ReactDOMClient, ReactDOMServer} = initModules());
act = require('internal-test-utils').act;
ReactFeatureFlags = require('shared/ReactFeatureFlags');
}
function shouldUseDocument(reactElement) {
return reactElement && reactElement.type === 'html';
}
function getContainerFromMarkup(reactElement, markup) {
if (shouldUseDocument(reactElement)) {
const doc = document.implementation.createHTMLDocument('');
doc.open();
doc.write(
markup ||
'<!doctype html><html><meta charset=utf-8><title>test doc</title>',
);
doc.close();
return doc;
} else {
const container = document.createElement('div');
container.innerHTML = markup;
return container;
}
}
async function asyncReactDOMRender(reactElement, domElement, forceHydrate) {
if (forceHydrate) {
await act(() => {
ReactDOMClient.hydrateRoot(domElement, reactElement, {
onRecoverableError(e) {
if (
e.message.startsWith(
'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
)
) {
} else {
console.error(e);
}
},
});
});
} else {
await act(() => {
if (ReactDOMClient) {
const root = ReactDOMClient.createRoot(domElement);
root.render(reactElement);
} else {
ReactDOM.render(reactElement, domElement);
}
});
}
}
async function expectErrors(fn, count) {
if (console.error.mockClear) {
console.error.mockClear();
} else {
spyOnDev(console, 'error').mockImplementation(() => {});
}
const result = await fn();
if (
console.error.mock &&
console.error.mock.calls &&
console.error.mock.calls.length !== 0
) {
const filteredWarnings = [];
for (let i = 0; i < console.error.mock.calls.length; i++) {
const args = console.error.mock.calls[i];
const [format, ...rest] = args;
if (!shouldIgnoreConsoleError(format, rest)) {
filteredWarnings.push(args);
}
}
if (filteredWarnings.length !== count) {
console.log(
`We expected ${count} warning(s), but saw ${filteredWarnings.length} warning(s).`,
);
if (filteredWarnings.length > 0) {
console.log(`We saw these warnings:`);
for (let i = 0; i < filteredWarnings.length; i++) {
console.log(...filteredWarnings[i]);
}
}
if (__DEV__) {
expect(console.error).toHaveBeenCalledTimes(count);
}
}
}
return result;
}
function renderIntoDom(
reactElement,
domElement,
forceHydrate,
errorCount = 0,
) {
return expectErrors(async () => {
await asyncReactDOMRender(reactElement, domElement, forceHydrate);
return domElement.firstChild;
}, errorCount);
}
async function renderIntoString(reactElement, errorCount = 0) {
return await expectErrors(
() =>
new Promise(resolve =>
resolve(ReactDOMServer.renderToString(reactElement)),
),
errorCount,
);
}
async function serverRender(reactElement, errorCount = 0) {
const markup = await renderIntoString(reactElement, errorCount);
return getContainerFromMarkup(reactElement, markup).firstChild;
}
class DrainWritable extends stream.Writable {
constructor(options) {
super(options);
this.buffer = '';
}
_write(chunk, encoding, cb) {
this.buffer += chunk;
cb();
}
}
async function renderIntoStream(reactElement, errorCount = 0) {
return await expectErrors(
() =>
new Promise((resolve, reject) => {
const writable = new DrainWritable();
const s = ReactDOMServer.renderToPipeableStream(reactElement, {
onShellError(e) {
reject(e);
},
});
s.pipe(writable);
writable.on('finish', () => resolve(writable.buffer));
}),
errorCount,
);
}
async function streamRender(reactElement, errorCount = 0) {
const markup = await renderIntoStream(reactElement, errorCount);
let firstNode = getContainerFromMarkup(reactElement, markup).firstChild;
if (firstNode && firstNode.nodeType === Node.DOCUMENT_TYPE_NODE) {
firstNode = firstNode.nextSibling;
}
return firstNode;
}
const clientCleanRender = (element, errorCount = 0) => {
if (shouldUseDocument(element)) {
return clientRenderOnServerString(element, errorCount);
}
const container = document.createElement('div');
return renderIntoDom(element, container, false, errorCount);
};
const clientRenderOnServerString = async (element, errorCount = 0) => {
const markup = await renderIntoString(element, errorCount);
resetModules();
const container = getContainerFromMarkup(element, markup);
let serverNode = container.firstChild;
const firstClientNode = await renderIntoDom(
element,
container,
true,
errorCount,
);
let clientNode = firstClientNode;
while (serverNode || clientNode) {
expect(serverNode != null).toBe(true);
expect(clientNode != null).toBe(true);
expect(clientNode.nodeType).toBe(serverNode.nodeType);
expect(serverNode === clientNode).toBe(true);
serverNode = serverNode.nextSibling;
clientNode = clientNode.nextSibling;
}
return firstClientNode;
};
function BadMarkupExpected() {}
const clientRenderOnBadMarkup = async (element, errorCount = 0) => {
const container = getContainerFromMarkup(
element,
shouldUseDocument(element)
? '<html><body><div id="badIdWhichWillCauseMismatch" /></body></html>'
: '<div id="badIdWhichWillCauseMismatch"></div>',
);
await renderIntoDom(element, container, true, errorCount + 1);
const hydratedTextContent =
container.lastChild && container.lastChild.textContent;
let cleanContainer;
if (shouldUseDocument(element)) {
cleanContainer = getContainerFromMarkup(
element,
'<html></html>',
).documentElement;
element = element.props.children;
} else {
cleanContainer = document.createElement('div');
}
await asyncReactDOMRender(element, cleanContainer, true);
const cleanTextContent =
(cleanContainer.lastChild && cleanContainer.lastChild.textContent) || '';
if (ReactFeatureFlags.favorSafetyOverHydrationPerf) {
expect(hydratedTextContent).toBe(cleanTextContent);
}
throw new BadMarkupExpected();
};
function itRenders(desc, testFn) {
it(`renders ${desc} with server string render`, () => testFn(serverRender));
it(`renders ${desc} with server stream render`, () => testFn(streamRender));
itClientRenders(desc, testFn);
}
function itClientRenders(desc, testFn) {
it(`renders ${desc} with clean client render`, () =>
testFn(clientCleanRender));
it(`renders ${desc} with client render on top of good server markup`, () =>
testFn(clientRenderOnServerString));
it(`renders ${desc} with client render on top of bad server markup`, async () => {
try {
await testFn(clientRenderOnBadMarkup);
} catch (x) {
if (!(x instanceof BadMarkupExpected)) {
throw x;
}
}
});
}
function itThrows(desc, testFn, partialMessage) {
it(`throws ${desc}`, () => {
return testFn().then(
() => expect(false).toBe('The promise resolved and should not have.'),
err => {
expect(err).toBeInstanceOf(Error);
expect(err.message).toContain(partialMessage);
},
);
});
}
function itThrowsWhenRendering(desc, testFn, partialMessage) {
itThrows(
`when rendering ${desc} with server string render`,
() => testFn(serverRender),
partialMessage,
);
itThrows(
`when rendering ${desc} with clean client render`,
() => testFn(clientCleanRender),
partialMessage,
);
itThrows(
`when rendering ${desc} with client render on top of bad server markup`,
() =>
testFn((element, warningCount = 0) =>
clientRenderOnBadMarkup(element, warningCount - 1),
),
partialMessage,
);
}
async function testMarkupMatch(serverElement, clientElement, shouldMatch) {
const domElement = await serverRender(serverElement);
resetModules();
return renderIntoDom(
clientElement,
domElement.parentNode,
true,
shouldMatch ? 0 : 1,
);
}
function expectMarkupMatch(serverElement, clientElement) {
return testMarkupMatch(serverElement, clientElement, true);
}
function expectMarkupMismatch(serverElement, clientElement) {
return testMarkupMatch(serverElement, clientElement, false);
}
return {
resetModules,
expectMarkupMismatch,
expectMarkupMatch,
itRenders,
itClientRenders,
itThrowsWhenRendering,
asyncReactDOMRender,
serverRender,
clientCleanRender,
clientRenderOnBadMarkup,
clientRenderOnServerString,
renderIntoDom,
streamRender,
};
};