main
js 649 lines 18.1 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 * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
9 */
10
11 'use strict';
12
13 let JSDOM;
14 let Stream;
15 let Scheduler;
16 let React;
17 let ReactDOMClient;
18 let ReactDOMFizzServer;
19 let document;
20 let writable;
21 let container;
22 let buffer = '';
23 let hasErrored = false;
24 let fatalError = undefined;
25 let waitForAll;
26
27 function normalizeError(msg) {
28 // Take the first sentence to make it easier to assert on.
29 const idx = msg.indexOf('.');
30 if (idx > -1) {
31 return msg.slice(0, idx + 1);
32 }
33 return msg;
34 }
35
36 describe('ReactDOMFizzServerHydrationWarning', () => {
37 beforeEach(() => {
38 jest.resetModules();
39 JSDOM = require('jsdom').JSDOM;
40 Scheduler = require('scheduler');
41 React = require('react');
42 ReactDOMClient = require('react-dom/client');
43 ReactDOMFizzServer = require('react-dom/server');
44 Stream = require('stream');
45
46 const InternalTestUtils = require('internal-test-utils');
47 waitForAll = InternalTestUtils.waitForAll;
48
49 // Test Environment
50 const jsdom = new JSDOM(
51 '<!DOCTYPE html><html><head></head><body><div id="container">',
52 {
53 runScripts: 'dangerously',
54 },
55 );
56 document = jsdom.window.document;
57 container = document.getElementById('container');
58
59 buffer = '';
60 hasErrored = false;
61
62 writable = new Stream.PassThrough();
63 writable.setEncoding('utf8');
64 writable.on('data', chunk => {
65 buffer += chunk;
66 });
67 writable.on('error', error => {
68 hasErrored = true;
69 fatalError = error;
70 });
71 });
72
73 async function act(callback) {
74 await callback();
75 // Await one turn around the event loop.
76 // This assumes that we'll flush everything we have so far.
77 await new Promise(resolve => {
78 setImmediate(resolve);
79 });
80 if (hasErrored) {
81 throw fatalError;
82 }
83 // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
84 // We also want to execute any scripts that are embedded.
85 // We assume that we have now received a proper fragment of HTML.
86 const bufferedContent = buffer;
87 buffer = '';
88 const fakeBody = document.createElement('body');
89 fakeBody.innerHTML = bufferedContent;
90 while (fakeBody.firstChild) {
91 const node = fakeBody.firstChild;
92 if (node.nodeName === 'SCRIPT') {
93 const script = document.createElement('script');
94 script.textContent = node.textContent;
95 fakeBody.removeChild(node);
96 container.appendChild(script);
97 } else {
98 container.appendChild(node);
99 }
100 }
101 }
102
103 function getVisibleChildren(element) {
104 const children = [];
105 let node = element.firstChild;
106 while (node) {
107 if (node.nodeType === 1) {
108 if (
109 node.tagName !== 'SCRIPT' &&
110 node.tagName !== 'TEMPLATE' &&
111 node.tagName !== 'template' &&
112 !node.hasAttribute('hidden') &&
113 !node.hasAttribute('aria-hidden')
114 ) {
115 const props = {};
116 const attributes = node.attributes;
117 for (let i = 0; i < attributes.length; i++) {
118 if (
119 attributes[i].name === 'id' &&
120 attributes[i].value.includes(':')
121 ) {
122 // We assume this is a React added ID that's a non-visual implementation detail.
123 continue;
124 }
125 props[attributes[i].name] = attributes[i].value;
126 }
127 props.children = getVisibleChildren(node);
128 children.push(React.createElement(node.tagName.toLowerCase(), props));
129 }
130 } else if (node.nodeType === 3) {
131 children.push(node.data);
132 }
133 node = node.nextSibling;
134 }
135 return children.length === 0
136 ? undefined
137 : children.length === 1
138 ? children[0]
139 : children;
140 }
141
142 it('suppresses but does not fix text mismatches with suppressHydrationWarning', async () => {
143 function App({isClient}) {
144 return (
145 <div>
146 <span suppressHydrationWarning={true}>
147 {isClient ? 'Client Text' : 'Server Text'}
148 </span>
149 <span suppressHydrationWarning={true}>{isClient ? 2 : 1}</span>
150 </div>
151 );
152 }
153 await act(() => {
154 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
155 <App isClient={false} />,
156 );
157 pipe(writable);
158 });
159 expect(getVisibleChildren(container)).toEqual(
160 <div>
161 <span>Server Text</span>
162 <span>1</span>
163 </div>,
164 );
165 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
166 onRecoverableError(error) {
167 // Don't miss a hydration error. There should be none.
168 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
169 if (error.cause) {
170 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
171 }
172 },
173 });
174 await waitForAll([]);
175 // The text mismatch should be *silently* fixed. Even in production.
176 expect(getVisibleChildren(container)).toEqual(
177 <div>
178 <span>Server Text</span>
179 <span>1</span>
180 </div>,
181 );
182 });
183
184 it('suppresses but does not fix multiple text node mismatches with suppressHydrationWarning', async () => {
185 function App({isClient}) {
186 return (
187 <div>
188 <span suppressHydrationWarning={true}>
189 {isClient ? 'Client1' : 'Server1'}
190 {isClient ? 'Client2' : 'Server2'}
191 </span>
192 </div>
193 );
194 }
195 await act(() => {
196 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
197 <App isClient={false} />,
198 );
199 pipe(writable);
200 });
201 expect(getVisibleChildren(container)).toEqual(
202 <div>
203 <span>
204 {'Server1'}
205 {'Server2'}
206 </span>
207 </div>,
208 );
209 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
210 onRecoverableError(error) {
211 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
212 if (error.cause) {
213 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
214 }
215 },
216 });
217 await waitForAll([]);
218 expect(getVisibleChildren(container)).toEqual(
219 <div>
220 <span>
221 {'Server1'}
222 {'Server2'}
223 </span>
224 </div>,
225 );
226 });
227
228 it('errors on text-to-element mismatches with suppressHydrationWarning', async () => {
229 function App({isClient}) {
230 return (
231 <div>
232 <span suppressHydrationWarning={true}>
233 Hello, {isClient ? <span>Client</span> : 'Server'}!
234 </span>
235 </div>
236 );
237 }
238 await act(() => {
239 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
240 <App isClient={false} />,
241 );
242 pipe(writable);
243 });
244 expect(getVisibleChildren(container)).toEqual(
245 <div>
246 <span>
247 {'Hello, '}
248 {'Server'}
249 {'!'}
250 </span>
251 </div>,
252 );
253 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
254 onRecoverableError(error) {
255 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
256 if (error.cause) {
257 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
258 }
259 },
260 });
261 await waitForAll([
262 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
263 ]);
264 expect(getVisibleChildren(container)).toEqual(
265 <div>
266 <span>
267 Hello, <span>Client</span>!
268 </span>
269 </div>,
270 );
271 });
272
273 it('suppresses but does not fix client-only single text node mismatches with suppressHydrationWarning', async () => {
274 function App({text}) {
275 return (
276 <div>
277 <span suppressHydrationWarning={true}>{text}</span>
278 </div>
279 );
280 }
281 await act(() => {
282 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
283 <App text={null} />,
284 );
285 pipe(writable);
286 });
287 expect(getVisibleChildren(container)).toEqual(
288 <div>
289 <span />
290 </div>,
291 );
292 const root = ReactDOMClient.hydrateRoot(container, <App text="Client" />, {
293 onRecoverableError(error) {
294 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
295 if (error.cause) {
296 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
297 }
298 },
299 });
300 await waitForAll([]);
301 expect(getVisibleChildren(container)).toEqual(
302 <div>
303 <span />
304 </div>,
305 );
306 // An update fixes it though.
307 root.render(<App text="Client 2" />);
308 await waitForAll([]);
309 expect(getVisibleChildren(container)).toEqual(
310 <div>
311 <span>Client 2</span>
312 </div>,
313 );
314 });
315
316 // TODO: This behavior is not consistent with client-only single text node.
317
318 it('errors on server-only single text node mismatches with suppressHydrationWarning', async () => {
319 function App({isClient}) {
320 return (
321 <div>
322 <span suppressHydrationWarning={true}>
323 {isClient ? null : 'Server'}
324 </span>
325 </div>
326 );
327 }
328 await act(() => {
329 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
330 <App isClient={false} />,
331 );
332 pipe(writable);
333 });
334 expect(getVisibleChildren(container)).toEqual(
335 <div>
336 <span>Server</span>
337 </div>,
338 );
339 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
340 onRecoverableError(error) {
341 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
342 if (error.cause) {
343 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
344 }
345 },
346 });
347 await waitForAll([
348 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
349 ]);
350 expect(getVisibleChildren(container)).toEqual(
351 <div>
352 <span />
353 </div>,
354 );
355 });
356
357 it('errors on client-only extra text node mismatches with suppressHydrationWarning', async () => {
358 function App({isClient}) {
359 return (
360 <div>
361 <span suppressHydrationWarning={true}>
362 <span>Shared</span>
363 {isClient ? 'Client' : null}
364 </span>
365 </div>
366 );
367 }
368 await act(() => {
369 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
370 <App isClient={false} />,
371 );
372 pipe(writable);
373 });
374 expect(getVisibleChildren(container)).toEqual(
375 <div>
376 <span>
377 <span>Shared</span>
378 </span>
379 </div>,
380 );
381 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
382 onRecoverableError(error) {
383 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
384 if (error.cause) {
385 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
386 }
387 },
388 });
389 await waitForAll([
390 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
391 ]);
392 expect(getVisibleChildren(container)).toEqual(
393 <div>
394 <span>
395 <span>Shared</span>
396 {'Client'}
397 </span>
398 </div>,
399 );
400 });
401
402 it('errors on server-only extra text node mismatches with suppressHydrationWarning', async () => {
403 function App({isClient}) {
404 return (
405 <div>
406 <span suppressHydrationWarning={true}>
407 <span>Shared</span>
408 {isClient ? null : 'Server'}
409 </span>
410 </div>
411 );
412 }
413 await act(() => {
414 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
415 <App isClient={false} />,
416 );
417 pipe(writable);
418 });
419 expect(getVisibleChildren(container)).toEqual(
420 <div>
421 <span>
422 <span>Shared</span>Server
423 </span>
424 </div>,
425 );
426 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
427 onRecoverableError(error) {
428 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
429 if (error.cause) {
430 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
431 }
432 },
433 });
434 await waitForAll([
435 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
436 ]);
437 expect(getVisibleChildren(container)).toEqual(
438 <div>
439 <span>
440 <span>Shared</span>
441 </span>
442 </div>,
443 );
444 });
445
446 it('errors on element-to-text mismatches with suppressHydrationWarning', async () => {
447 function App({isClient}) {
448 return (
449 <div>
450 <span suppressHydrationWarning={true}>
451 Hello, {isClient ? 'Client' : <span>Server</span>}!
452 </span>
453 </div>
454 );
455 }
456 await act(() => {
457 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
458 <App isClient={false} />,
459 );
460 pipe(writable);
461 });
462 expect(getVisibleChildren(container)).toEqual(
463 <div>
464 <span>
465 Hello, <span>Server</span>!
466 </span>
467 </div>,
468 );
469 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
470 onRecoverableError(error) {
471 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
472 if (error.cause) {
473 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
474 }
475 },
476 });
477 await waitForAll([
478 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
479 ]);
480 expect(getVisibleChildren(container)).toEqual(
481 <div>
482 <span>
483 {'Hello, '}
484 {'Client'}
485 {'!'}
486 </span>
487 </div>,
488 );
489 });
490
491 it('suppresses but does not fix attribute mismatches with suppressHydrationWarning', async () => {
492 function App({isClient}) {
493 return (
494 <div>
495 <span
496 suppressHydrationWarning={true}
497 className={isClient ? 'client' : 'server'}
498 style={{opacity: isClient ? 1 : 0}}
499 data-serveronly={isClient ? null : 'server-only'}
500 data-clientonly={isClient ? 'client-only' : null}
501 />
502 </div>
503 );
504 }
505 await act(() => {
506 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
507 <App isClient={false} />,
508 );
509 pipe(writable);
510 });
511 expect(getVisibleChildren(container)).toEqual(
512 <div>
513 <span class="server" style="opacity:0" data-serveronly="server-only" />
514 </div>,
515 );
516 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
517 onRecoverableError(error) {
518 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
519 if (error.cause) {
520 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
521 }
522 },
523 });
524 await waitForAll([]);
525 expect(getVisibleChildren(container)).toEqual(
526 <div>
527 <span class="server" style="opacity:0" data-serveronly="server-only" />
528 </div>,
529 );
530 });
531
532 it('suppresses and does not fix html mismatches with suppressHydrationWarning', async () => {
533 function App({isClient}) {
534 return (
535 <div>
536 <p
537 suppressHydrationWarning={true}
538 dangerouslySetInnerHTML={{
539 __html: isClient ? 'Client HTML' : 'Server HTML',
540 }}
541 />
542 </div>
543 );
544 }
545 await act(() => {
546 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
547 <App isClient={false} />,
548 );
549 pipe(writable);
550 });
551 expect(getVisibleChildren(container)).toEqual(
552 <div>
553 <p>Server HTML</p>
554 </div>,
555 );
556 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
557 onRecoverableError(error) {
558 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
559 if (error.cause) {
560 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
561 }
562 },
563 });
564 await waitForAll([]);
565 expect(getVisibleChildren(container)).toEqual(
566 <div>
567 <p>Server HTML</p>
568 </div>,
569 );
570 });
571
572 it('errors on insertions with suppressHydrationWarning', async () => {
573 function App({isClient}) {
574 return (
575 <div suppressHydrationWarning={true}>
576 <p>Client and server</p>
577 {isClient && <p>Client only</p>}
578 </div>
579 );
580 }
581 await act(() => {
582 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
583 <App isClient={false} />,
584 );
585 pipe(writable);
586 });
587 expect(getVisibleChildren(container)).toEqual(
588 <div>
589 <p>Client and server</p>
590 </div>,
591 );
592 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
593 onRecoverableError(error) {
594 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
595 if (error.cause) {
596 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
597 }
598 },
599 });
600 await waitForAll([
601 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
602 ]);
603 expect(getVisibleChildren(container)).toEqual(
604 <div>
605 <p>Client and server</p>
606 <p>Client only</p>
607 </div>,
608 );
609 });
610
611 it('errors on deletions with suppressHydrationWarning', async () => {
612 function App({isClient}) {
613 return (
614 <div suppressHydrationWarning={true}>
615 <p>Client and server</p>
616 {!isClient && <p>Server only</p>}
617 </div>
618 );
619 }
620 await act(() => {
621 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
622 <App isClient={false} />,
623 );
624 pipe(writable);
625 });
626 expect(getVisibleChildren(container)).toEqual(
627 <div>
628 <p>Client and server</p>
629 <p>Server only</p>
630 </div>,
631 );
632 ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
633 onRecoverableError(error) {
634 Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
635 if (error.cause) {
636 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
637 }
638 },
639 });
640 await waitForAll([
641 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
642 ]);
643 expect(getVisibleChildren(container)).toEqual(
644 <div>
645 <p>Client and server</p>
646 </div>,
647 );
648 });
649 });