main
js 447 lines 13.3 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 let React = require('react');
13 let ReactDOM = require('react-dom');
14 let ReactDOMClient = require('react-dom/client');
15 let ReactDOMServer = require('react-dom/server');
16 let Scheduler = require('scheduler');
17 let act;
18 let useEffect;
19 let assertLog;
20 let waitForAll;
21 let assertConsoleErrorDev;
22
23 describe('ReactDOMRoot', () => {
24 let container;
25
26 beforeEach(() => {
27 jest.resetModules();
28 container = document.createElement('div');
29 React = require('react');
30 ReactDOM = require('react-dom');
31 ReactDOMClient = require('react-dom/client');
32 ReactDOMServer = require('react-dom/server');
33 Scheduler = require('scheduler');
34 act = require('internal-test-utils').act;
35 assertConsoleErrorDev =
36 require('internal-test-utils').assertConsoleErrorDev;
37 useEffect = React.useEffect;
38
39 const InternalTestUtils = require('internal-test-utils');
40 assertLog = InternalTestUtils.assertLog;
41 waitForAll = InternalTestUtils.waitForAll;
42 });
43
44 it('renders children', async () => {
45 const root = ReactDOMClient.createRoot(container);
46 root.render(<div>Hi</div>);
47 await waitForAll([]);
48 expect(container.textContent).toEqual('Hi');
49 });
50
51 it('warns if a callback parameter is provided to render', async () => {
52 const callback = jest.fn();
53 const root = ReactDOMClient.createRoot(container);
54 root.render(<div>Hi</div>, callback);
55 assertConsoleErrorDev([
56 'does not support the second callback argument. ' +
57 'To execute a side effect after rendering, declare it in a component body with useEffect().',
58 ]);
59 await waitForAll([]);
60 expect(callback).not.toHaveBeenCalled();
61 });
62
63 it('warn if a object is passed to root.render(...)', async () => {
64 function App() {
65 return 'Child';
66 }
67
68 const root = ReactDOMClient.createRoot(container);
69 root.render(<App />, {});
70 assertConsoleErrorDev([
71 'You passed a second argument to root.render(...) but it only accepts ' +
72 'one argument.',
73 ]);
74 });
75
76 it('warn if a container is passed to root.render(...)', async () => {
77 function App() {
78 return 'Child';
79 }
80
81 const root = ReactDOMClient.createRoot(container);
82 root.render(<App />, container);
83 assertConsoleErrorDev([
84 'You passed a container to the second argument of root.render(...). ' +
85 "You don't need to pass it again since you already passed it to create " +
86 'the root.',
87 ]);
88 });
89
90 it('warns if a callback parameter is provided to unmount', async () => {
91 const callback = jest.fn();
92 const root = ReactDOMClient.createRoot(container);
93 root.render(<div>Hi</div>);
94 root.unmount(callback);
95 assertConsoleErrorDev([
96 'does not support a callback argument. ' +
97 'To execute a side effect after rendering, declare it in a component body with useEffect().',
98 ]);
99 await waitForAll([]);
100 expect(callback).not.toHaveBeenCalled();
101 });
102
103 it('unmounts children', async () => {
104 const root = ReactDOMClient.createRoot(container);
105 root.render(<div>Hi</div>);
106 await waitForAll([]);
107 expect(container.textContent).toEqual('Hi');
108 root.unmount();
109 await waitForAll([]);
110 expect(container.textContent).toEqual('');
111 });
112
113 it('can be immediately unmounted', async () => {
114 const root = ReactDOMClient.createRoot(container);
115 await act(() => {
116 root.unmount();
117 });
118 });
119
120 it('supports hydration', async () => {
121 const markup = await new Promise(resolve =>
122 resolve(
123 ReactDOMServer.renderToString(
124 <div>
125 <span className="extra" />
126 </div>,
127 ),
128 ),
129 );
130
131 // Does not hydrate by default
132 const container1 = document.createElement('div');
133 container1.innerHTML = markup;
134 const root1 = ReactDOMClient.createRoot(container1);
135 root1.render(
136 <div>
137 <span />
138 </div>,
139 );
140 await waitForAll([]);
141
142 const container2 = document.createElement('div');
143 container2.innerHTML = markup;
144 ReactDOMClient.hydrateRoot(
145 container2,
146 <div>
147 <span />
148 </div>,
149 );
150 await waitForAll([]);
151 assertConsoleErrorDev([
152 "A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. " +
153 "This won't be patched up. This can happen if a SSR-ed Client Component used:\n" +
154 '\n' +
155 "- A server/client branch `if (typeof window !== 'undefined')`.\n" +
156 "- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.\n" +
157 "- Date formatting in a user's locale which doesn't match the server.\n" +
158 '- External changing data without sending a snapshot of it along with the HTML.\n' +
159 '- Invalid HTML tag nesting.\n' +
160 '\n' +
161 'It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.\n' +
162 '\n' +
163 'https://react.dev/link/hydration-mismatch\n' +
164 '\n' +
165 ' <div>\n' +
166 ' <span\n' +
167 '- className="extra"\n' +
168 ' >\n' +
169 '\n in span (at **)',
170 ]);
171 });
172
173 it('clears existing children', async () => {
174 container.innerHTML = '<div>a</div><div>b</div>';
175 const root = ReactDOMClient.createRoot(container);
176 root.render(
177 <div>
178 <span>c</span>
179 <span>d</span>
180 </div>,
181 );
182 await waitForAll([]);
183 expect(container.textContent).toEqual('cd');
184 root.render(
185 <div>
186 <span>d</span>
187 <span>c</span>
188 </div>,
189 );
190 await waitForAll([]);
191 expect(container.textContent).toEqual('dc');
192 });
193
194 it('throws a good message on invalid containers', () => {
195 expect(() => {
196 ReactDOMClient.createRoot(<div>Hi</div>);
197 }).toThrow('Target container is not a DOM element.');
198 });
199
200 it('warns when creating two roots managing the same container', () => {
201 ReactDOMClient.createRoot(container);
202 ReactDOMClient.createRoot(container);
203 assertConsoleErrorDev([
204 'You are calling ReactDOMClient.createRoot() on a container that ' +
205 'has already been passed to createRoot() before. Instead, call ' +
206 'root.render() on the existing root instead if you want to update it.',
207 ]);
208 });
209
210 it('does not warn when creating second root after first one is unmounted', async () => {
211 const root = ReactDOMClient.createRoot(container);
212 root.unmount();
213 await waitForAll([]);
214 ReactDOMClient.createRoot(container); // No warning
215 });
216
217 it('warns if creating a root on the document.body', async () => {
218 // we no longer expect an error for this if float is enabled
219 ReactDOMClient.createRoot(document.body);
220 });
221
222 it('warns if updating a root that has had its contents removed', async () => {
223 const root = ReactDOMClient.createRoot(container);
224 root.render(<div>Hi</div>);
225 await waitForAll([]);
226 container.innerHTML = '';
227
228 // When either of these flags are on this validation is turned off so we
229 // expect there to be no warnings
230 root.render(<div>Hi</div>);
231 });
232
233 it('should render different components in same root', async () => {
234 document.body.appendChild(container);
235 const root = ReactDOMClient.createRoot(container);
236
237 await act(() => {
238 root.render(<div />);
239 });
240 expect(container.firstChild.nodeName).toBe('DIV');
241
242 await act(() => {
243 root.render(<span />);
244 });
245 expect(container.firstChild.nodeName).toBe('SPAN');
246 });
247
248 it('should not warn if mounting into non-empty node', async () => {
249 container.innerHTML = '<div></div>';
250 const root = ReactDOMClient.createRoot(container);
251 await act(() => {
252 root.render(<div />);
253 });
254
255 expect(true).toBe(true);
256 });
257
258 it('should reuse markup if rendering to the same target twice', async () => {
259 const root = ReactDOMClient.createRoot(container);
260 await act(() => {
261 root.render(<div />);
262 });
263 const firstElm = container.firstChild;
264 await act(() => {
265 root.render(<div />);
266 });
267
268 expect(firstElm).toBe(container.firstChild);
269 });
270
271 it('should unmount and remount if the key changes', async () => {
272 function Component({text}) {
273 useEffect(() => {
274 Scheduler.log('Mount');
275
276 return () => {
277 Scheduler.log('Unmount');
278 };
279 }, []);
280
281 return <span>{text}</span>;
282 }
283
284 const root = ReactDOMClient.createRoot(container);
285
286 await act(() => {
287 root.render(<Component text="orange" key="A" />);
288 });
289 expect(container.firstChild.innerHTML).toBe('orange');
290 assertLog(['Mount']);
291
292 // If we change the key, the component is unmounted and remounted
293 await act(() => {
294 root.render(<Component text="green" key="B" />);
295 });
296 expect(container.firstChild.innerHTML).toBe('green');
297 assertLog(['Unmount', 'Mount']);
298
299 // But if we don't change the key, the component instance is reused
300 await act(() => {
301 root.render(<Component text="blue" key="B" />);
302 });
303 expect(container.firstChild.innerHTML).toBe('blue');
304 assertLog([]);
305 });
306
307 it('throws if unmounting a root that has had its contents removed', async () => {
308 const root = ReactDOMClient.createRoot(container);
309 await act(() => {
310 root.render(<div>Hi</div>);
311 });
312 container.innerHTML = '';
313
314 await expect(async () => {
315 await act(() => {
316 root.unmount();
317 });
318 }).rejects.toThrow('The node to be removed is not a child of this node.');
319 });
320
321 it('unmount is synchronous', async () => {
322 const root = ReactDOMClient.createRoot(container);
323 await act(() => {
324 root.render('Hi');
325 });
326 expect(container.textContent).toEqual('Hi');
327
328 await act(() => {
329 root.unmount();
330 // Should have already unmounted
331 expect(container.textContent).toEqual('');
332 });
333 });
334
335 it('throws if an unmounted root is updated', async () => {
336 const root = ReactDOMClient.createRoot(container);
337 await act(() => {
338 root.render('Hi');
339 });
340 expect(container.textContent).toEqual('Hi');
341
342 root.unmount();
343
344 expect(() => root.render("I'm back")).toThrow(
345 'Cannot update an unmounted root.',
346 );
347 });
348
349 it('warns if root is unmounted inside an effect', async () => {
350 const container1 = document.createElement('div');
351 const root1 = ReactDOMClient.createRoot(container1);
352 const container2 = document.createElement('div');
353 const root2 = ReactDOMClient.createRoot(container2);
354
355 function App({step}) {
356 useEffect(() => {
357 if (step === 2) {
358 root2.unmount();
359 }
360 }, [step]);
361 return 'Hi';
362 }
363
364 await act(() => {
365 root1.render(<App step={1} />);
366 });
367 expect(container1.textContent).toEqual('Hi');
368
369 ReactDOM.flushSync(() => {
370 root1.render(<App step={2} />);
371 });
372 assertConsoleErrorDev([
373 'Attempted to synchronously unmount a root while React was already rendering. ' +
374 'React cannot finish unmounting the root until the current render has completed, ' +
375 'which may lead to a race condition.\n' +
376 ' in App (at **)',
377 ]);
378 });
379
380 // @gate disableCommentsAsDOMContainers
381 it('errors if container is a comment node', () => {
382 // This is an old feature used by www. Disabled in the open source build.
383 const div = document.createElement('div');
384 div.innerHTML = '<!-- react-mount-point-unstable -->';
385 const commentNode = div.childNodes[0];
386
387 expect(() => ReactDOMClient.createRoot(commentNode)).toThrow(
388 'Target container is not a DOM element.',
389 );
390 expect(() => ReactDOMClient.hydrateRoot(commentNode)).toThrow(
391 'Target container is not a DOM element.',
392 );
393 });
394
395 it('warn if no children passed to hydrateRoot', async () => {
396 ReactDOMClient.hydrateRoot(container);
397 assertConsoleErrorDev([
398 'Must provide initial children as second argument to hydrateRoot. ' +
399 'Example usage: hydrateRoot(domContainer, <App />)',
400 ]);
401 });
402
403 it('warn if JSX passed to createRoot', async () => {
404 function App() {
405 return 'Child';
406 }
407
408 ReactDOMClient.createRoot(container, <App />);
409 assertConsoleErrorDev([
410 'You passed a JSX element to createRoot. You probably meant to call root.render instead. ' +
411 'Example usage:\n' +
412 '\n' +
413 ' let root = createRoot(domContainer);\n' +
414 ' root.render(<App />);',
415 ]);
416 });
417
418 it('warns when given a function', () => {
419 function Component() {
420 return <div />;
421 }
422
423 const root = ReactDOMClient.createRoot(document.createElement('div'));
424
425 ReactDOM.flushSync(() => {
426 root.render(Component);
427 });
428 assertConsoleErrorDev([
429 'Functions are not valid as a React child. ' +
430 'This may happen if you return Component instead of <Component /> from render. ' +
431 'Or maybe you meant to call this function rather than return it.\n' +
432 ' root.render(Component)',
433 ]);
434 });
435
436 it('warns when given a symbol', () => {
437 const root = ReactDOMClient.createRoot(document.createElement('div'));
438
439 ReactDOM.flushSync(() => {
440 root.render(Symbol('foo'));
441 });
442 assertConsoleErrorDev([
443 'Symbols are not valid as a React child.\n' +
444 ' root.render(Symbol(foo))',
445 ]);
446 });
447 });