main
js 408 lines 13.6 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @emails react-core
8 */
9
10 'use strict';
11
12 const stream = require('stream');
13 const shouldIgnoreConsoleError = require('internal-test-utils/shouldIgnoreConsoleError');
14
15 module.exports = function (initModules) {
16 let ReactDOM;
17 let ReactDOMClient;
18 let ReactDOMServer;
19 let act;
20
21 function resetModules() {
22 ({ReactDOM, ReactDOMClient, ReactDOMServer} = initModules());
23 act = require('internal-test-utils').act;
24 }
25
26 function shouldUseDocument(reactElement) {
27 // Used for whole document tests.
28 return reactElement && reactElement.type === 'html';
29 }
30
31 function getContainerFromMarkup(reactElement, markup) {
32 if (shouldUseDocument(reactElement)) {
33 const doc = document.implementation.createHTMLDocument('');
34 doc.open();
35 doc.write(
36 markup ||
37 '<!doctype html><html><meta charset=utf-8><title>test doc</title>',
38 );
39 doc.close();
40 return doc;
41 } else {
42 const container = document.createElement('div');
43 container.innerHTML = markup;
44 return container;
45 }
46 }
47
48 // Helper functions for rendering tests
49 // ====================================
50
51 // promisified version of ReactDOM.render()
52 async function asyncReactDOMRender(reactElement, domElement, forceHydrate) {
53 if (forceHydrate) {
54 await act(() => {
55 ReactDOMClient.hydrateRoot(domElement, reactElement, {
56 onRecoverableError(e) {
57 if (
58 e.message.startsWith(
59 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
60 )
61 ) {
62 // We ignore this extra error because it shouldn't really need to be there if
63 // a hydration mismatch is the cause of it.
64 } else {
65 console.error(e);
66 }
67 },
68 });
69 });
70 } else {
71 await act(() => {
72 if (ReactDOMClient) {
73 const root = ReactDOMClient.createRoot(domElement);
74 root.render(reactElement);
75 } else {
76 ReactDOM.render(reactElement, domElement);
77 }
78 });
79 }
80 }
81 // performs fn asynchronously and expects count errors logged to console.error.
82 // will fail the test if the count of errors logged is not equal to count.
83 async function expectErrors(fn, count) {
84 if (console.error.mockClear) {
85 console.error.mockClear();
86 } else {
87 // TODO: Rewrite tests that use this helper to enumerate expected errors.
88 // This will enable the helper to use the assertConsoleErrorDev instead of spying.
89 spyOnDev(console, 'error').mockImplementation(() => {});
90 }
91
92 const result = await fn();
93 if (
94 console.error.mock &&
95 console.error.mock.calls &&
96 console.error.mock.calls.length !== 0
97 ) {
98 const filteredWarnings = [];
99 for (let i = 0; i < console.error.mock.calls.length; i++) {
100 const args = console.error.mock.calls[i];
101 const [format, ...rest] = args;
102 if (!shouldIgnoreConsoleError(format, rest)) {
103 filteredWarnings.push(args);
104 }
105 }
106 if (filteredWarnings.length !== count) {
107 console.log(
108 `We expected ${count} warning(s), but saw ${filteredWarnings.length} warning(s).`,
109 );
110 if (filteredWarnings.length > 0) {
111 console.log(`We saw these warnings:`);
112 for (let i = 0; i < filteredWarnings.length; i++) {
113 console.log(...filteredWarnings[i]);
114 }
115 }
116 if (__DEV__) {
117 expect(console.error).toHaveBeenCalledTimes(count);
118 }
119 }
120 }
121 return result;
122 }
123
124 // renders the reactElement into domElement, and expects a certain number of errors.
125 // returns a Promise that resolves when the render is complete.
126 function renderIntoDom(
127 reactElement,
128 domElement,
129 forceHydrate,
130 errorCount = 0,
131 ) {
132 return expectErrors(async () => {
133 await asyncReactDOMRender(reactElement, domElement, forceHydrate);
134 return domElement.firstChild;
135 }, errorCount);
136 }
137
138 async function renderIntoString(reactElement, errorCount = 0) {
139 return await expectErrors(
140 () =>
141 new Promise(resolve =>
142 resolve(ReactDOMServer.renderToString(reactElement)),
143 ),
144 errorCount,
145 );
146 }
147
148 // Renders text using SSR and then stuffs it into a DOM node; returns the DOM
149 // element that corresponds with the reactElement.
150 // Does not render on client or perform client-side revival.
151 async function serverRender(reactElement, errorCount = 0) {
152 const markup = await renderIntoString(reactElement, errorCount);
153 return getContainerFromMarkup(reactElement, markup).firstChild;
154 }
155
156 // this just drains a readable piped into it to a string, which can be accessed
157 // via .buffer.
158 class DrainWritable extends stream.Writable {
159 constructor(options) {
160 super(options);
161 this.buffer = '';
162 }
163
164 _write(chunk, encoding, cb) {
165 this.buffer += chunk;
166 cb();
167 }
168 }
169
170 async function renderIntoStream(reactElement, errorCount = 0) {
171 return await expectErrors(
172 () =>
173 new Promise((resolve, reject) => {
174 const writable = new DrainWritable();
175 const s = ReactDOMServer.renderToPipeableStream(reactElement, {
176 onShellError(e) {
177 reject(e);
178 },
179 });
180 s.pipe(writable);
181 writable.on('finish', () => resolve(writable.buffer));
182 }),
183 errorCount,
184 );
185 }
186
187 // Renders text using node stream SSR and then stuffs it into a DOM node;
188 // returns the DOM element that corresponds with the reactElement.
189 // Does not render on client or perform client-side revival.
190 async function streamRender(reactElement, errorCount = 0) {
191 const markup = await renderIntoStream(reactElement, errorCount);
192 let firstNode = getContainerFromMarkup(reactElement, markup).firstChild;
193 if (firstNode && firstNode.nodeType === Node.DOCUMENT_TYPE_NODE) {
194 // Skip document type nodes.
195 firstNode = firstNode.nextSibling;
196 }
197 return firstNode;
198 }
199
200 const clientCleanRender = (element, errorCount = 0) => {
201 if (shouldUseDocument(element)) {
202 // Documents can't be rendered from scratch.
203 return clientRenderOnServerString(element, errorCount);
204 }
205 const container = document.createElement('div');
206 return renderIntoDom(element, container, false, errorCount);
207 };
208
209 const clientRenderOnServerString = async (element, errorCount = 0) => {
210 const markup = await renderIntoString(element, errorCount);
211 resetModules();
212
213 const container = getContainerFromMarkup(element, markup);
214 let serverNode = container.firstChild;
215
216 const firstClientNode = await renderIntoDom(
217 element,
218 container,
219 true,
220 errorCount,
221 );
222 let clientNode = firstClientNode;
223
224 // Make sure all top level nodes match up
225 while (serverNode || clientNode) {
226 expect(serverNode != null).toBe(true);
227 expect(clientNode != null).toBe(true);
228 expect(clientNode.nodeType).toBe(serverNode.nodeType);
229 // Assert that the DOM element hasn't been replaced.
230 // Note that we cannot use expect(serverNode).toBe(clientNode) because
231 // of jest bug #1772.
232 expect(serverNode === clientNode).toBe(true);
233 serverNode = serverNode.nextSibling;
234 clientNode = clientNode.nextSibling;
235 }
236 return firstClientNode;
237 };
238
239 function BadMarkupExpected() {}
240
241 const clientRenderOnBadMarkup = async (element, errorCount = 0) => {
242 // First we render the top of bad mark up.
243
244 const container = getContainerFromMarkup(
245 element,
246 shouldUseDocument(element)
247 ? '<html><body><div id="badIdWhichWillCauseMismatch" /></body></html>'
248 : '<div id="badIdWhichWillCauseMismatch"></div>',
249 );
250
251 await renderIntoDom(element, container, true, errorCount + 1);
252
253 // This gives us the resulting text content.
254 const hydratedTextContent =
255 container.lastChild && container.lastChild.textContent;
256
257 // Next we render the element into a clean DOM node client side.
258 let cleanContainer;
259 if (shouldUseDocument(element)) {
260 // We can't render into a document during a clean render,
261 // so instead, we'll render the children into the document element.
262 cleanContainer = getContainerFromMarkup(
263 element,
264 '<html></html>',
265 ).documentElement;
266 element = element.props.children;
267 } else {
268 cleanContainer = document.createElement('div');
269 }
270 await asyncReactDOMRender(element, cleanContainer, true);
271 // This gives us the expected text content.
272 const cleanTextContent =
273 (cleanContainer.lastChild && cleanContainer.lastChild.textContent) || '';
274
275 // The only guarantee is that text content has been patched up if needed.
276 expect(hydratedTextContent).toBe(cleanTextContent);
277
278 // Abort any further expects. All bets are off at this point.
279 throw new BadMarkupExpected();
280 };
281
282 // runs a DOM rendering test as four different tests, with four different rendering
283 // scenarios:
284 // -- render to string on server
285 // -- render on client without any server markup "clean client render"
286 // -- render on client on top of good server-generated string markup
287 // -- render on client on top of bad server-generated markup
288 //
289 // testFn is a test that has one arg, which is a render function. the render
290 // function takes in a ReactElement and an optional expected error count and
291 // returns a promise of a DOM Element.
292 //
293 // You should only perform tests that examine the DOM of the results of
294 // render; you should not depend on the interactivity of the returned DOM element,
295 // as that will not work in the server string scenario.
296 function itRenders(desc, testFn) {
297 it(`renders ${desc} with server string render`, () => testFn(serverRender));
298 it(`renders ${desc} with server stream render`, () => testFn(streamRender));
299 itClientRenders(desc, testFn);
300 }
301
302 // run testFn in three different rendering scenarios:
303 // -- render on client without any server markup "clean client render"
304 // -- render on client on top of good server-generated string markup
305 // -- render on client on top of bad server-generated markup
306 //
307 // testFn is a test that has one arg, which is a render function. the render
308 // function takes in a ReactElement and an optional expected error count and
309 // returns a promise of a DOM Element.
310 //
311 // Since all of the renders in this function are on the client, you can test interactivity,
312 // unlike with itRenders.
313 function itClientRenders(desc, testFn) {
314 it(`renders ${desc} with clean client render`, () =>
315 testFn(clientCleanRender));
316 it(`renders ${desc} with client render on top of good server markup`, () =>
317 testFn(clientRenderOnServerString));
318 it(`renders ${desc} with client render on top of bad server markup`, async () => {
319 try {
320 await testFn(clientRenderOnBadMarkup);
321 } catch (x) {
322 // We expect this to trigger the BadMarkupExpected rejection.
323 if (!(x instanceof BadMarkupExpected)) {
324 // If not, rethrow.
325 throw x;
326 }
327 }
328 });
329 }
330
331 function itThrows(desc, testFn, partialMessage) {
332 it(`throws ${desc}`, () => {
333 return testFn().then(
334 () => expect(false).toBe('The promise resolved and should not have.'),
335 err => {
336 expect(err).toBeInstanceOf(Error);
337 expect(err.message).toContain(partialMessage);
338 },
339 );
340 });
341 }
342
343 function itThrowsWhenRendering(desc, testFn, partialMessage) {
344 itThrows(
345 `when rendering ${desc} with server string render`,
346 () => testFn(serverRender),
347 partialMessage,
348 );
349 itThrows(
350 `when rendering ${desc} with clean client render`,
351 () => testFn(clientCleanRender),
352 partialMessage,
353 );
354
355 // we subtract one from the warning count here because the throw means that it won't
356 // get the usual markup mismatch warning.
357 itThrows(
358 `when rendering ${desc} with client render on top of bad server markup`,
359 () =>
360 testFn((element, warningCount = 0) =>
361 clientRenderOnBadMarkup(element, warningCount - 1),
362 ),
363 partialMessage,
364 );
365 }
366
367 // renders serverElement to a string, sticks it into a DOM element, and then
368 // tries to render clientElement on top of it. shouldMatch is a boolean
369 // telling whether we should expect the markup to match or not.
370 async function testMarkupMatch(serverElement, clientElement, shouldMatch) {
371 const domElement = await serverRender(serverElement);
372 resetModules();
373 return renderIntoDom(
374 clientElement,
375 domElement.parentNode,
376 true,
377 shouldMatch ? 0 : 1,
378 );
379 }
380
381 // expects that rendering clientElement on top of a server-rendered
382 // serverElement does NOT raise a markup mismatch warning.
383 function expectMarkupMatch(serverElement, clientElement) {
384 return testMarkupMatch(serverElement, clientElement, true);
385 }
386
387 // expects that rendering clientElement on top of a server-rendered
388 // serverElement DOES raise a markup mismatch warning.
389 function expectMarkupMismatch(serverElement, clientElement) {
390 return testMarkupMatch(serverElement, clientElement, false);
391 }
392
393 return {
394 resetModules,
395 expectMarkupMismatch,
396 expectMarkupMatch,
397 itRenders,
398 itClientRenders,
399 itThrowsWhenRendering,
400 asyncReactDOMRender,
401 serverRender,
402 clientCleanRender,
403 clientRenderOnBadMarkup,
404 clientRenderOnServerString,
405 renderIntoDom,
406 streamRender,
407 };
408 };