main
js 568 lines 15 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 React = require('react');
13 const ReactDOMClient = require('react-dom/client');
14 const act = require('internal-test-utils').act;
15
16 /**
17 * Tests that when a ref hops around children, we can track that correctly.
18 */
19 describe('ref swapping', () => {
20 let RefHopsAround;
21 beforeEach(() => {
22 RefHopsAround = class extends React.Component {
23 container = null;
24 state = {count: 0};
25 hopRef = React.createRef();
26 divOneRef = React.createRef();
27 divTwoRef = React.createRef();
28 divThreeRef = React.createRef();
29
30 moveRef = () => {
31 this.setState({count: this.state.count + 1});
32 };
33
34 render() {
35 const count = this.state.count;
36 /**
37 * What we have here, is three divs with refs (div1/2/3), but a single
38 * moving cursor ref `hopRef` that "hops" around the three. We'll call the
39 * `moveRef()` function several times and make sure that the hop ref
40 * points to the correct divs.
41 */
42 return (
43 <div ref={current => (this.container = current)}>
44 <div
45 className="first"
46 ref={count % 3 === 0 ? this.hopRef : this.divOneRef}
47 />
48 <div
49 className="second"
50 ref={count % 3 === 1 ? this.hopRef : this.divTwoRef}
51 />
52 <div
53 className="third"
54 ref={count % 3 === 2 ? this.hopRef : this.divThreeRef}
55 />
56 </div>
57 );
58 }
59 };
60 });
61
62 it('Allow refs to hop around children correctly', async () => {
63 const container = document.createElement('div');
64 const root = ReactDOMClient.createRoot(container);
65
66 let refHopsAround;
67 await act(() => {
68 root.render(<RefHopsAround ref={current => (refHopsAround = current)} />);
69 });
70
71 const firstDiv = refHopsAround.container.querySelector('.first');
72 const secondDiv = refHopsAround.container.querySelector('.second');
73 const thirdDiv = refHopsAround.container.querySelector('.third');
74
75 expect(refHopsAround.hopRef.current).toEqual(firstDiv);
76 expect(refHopsAround.divTwoRef.current).toEqual(secondDiv);
77 expect(refHopsAround.divThreeRef.current).toEqual(thirdDiv);
78
79 await act(() => {
80 refHopsAround.moveRef();
81 });
82 expect(refHopsAround.divOneRef.current).toEqual(firstDiv);
83 expect(refHopsAround.hopRef.current).toEqual(secondDiv);
84 expect(refHopsAround.divThreeRef.current).toEqual(thirdDiv);
85
86 await act(() => {
87 refHopsAround.moveRef();
88 });
89 expect(refHopsAround.divOneRef.current).toEqual(firstDiv);
90 expect(refHopsAround.divTwoRef.current).toEqual(secondDiv);
91 expect(refHopsAround.hopRef.current).toEqual(thirdDiv);
92
93 /**
94 * Make sure that after the third, we're back to where we started and the
95 * refs are completely restored.
96 */
97 await act(() => {
98 refHopsAround.moveRef();
99 });
100 expect(refHopsAround.hopRef.current).toEqual(firstDiv);
101 expect(refHopsAround.divTwoRef.current).toEqual(secondDiv);
102 expect(refHopsAround.divThreeRef.current).toEqual(thirdDiv);
103 });
104
105 it('always has a value for this.refs', async () => {
106 class Component extends React.Component {
107 render() {
108 return <div />;
109 }
110 }
111
112 const container = document.createElement('div');
113 const root = ReactDOMClient.createRoot(container);
114 let instance;
115 await act(() => {
116 root.render(<Component ref={current => (instance = current)} />);
117 });
118 expect(!!instance.refs).toBe(true);
119 });
120
121 it('ref called correctly for stateless component', async () => {
122 let refCalled = 0;
123 function Inner(props) {
124 return <a ref={props.saveA} />;
125 }
126
127 class Outer extends React.Component {
128 saveA = () => {
129 refCalled++;
130 };
131
132 componentDidMount() {
133 this.setState({});
134 }
135
136 render() {
137 return <Inner saveA={this.saveA} />;
138 }
139 }
140
141 const container = document.createElement('div');
142 const root = ReactDOMClient.createRoot(container);
143 await act(() => {
144 root.render(<Outer />);
145 });
146
147 expect(refCalled).toBe(1);
148 });
149
150 it('provides an error for invalid refs', async () => {
151 const container = document.createElement('div');
152 const root = ReactDOMClient.createRoot(container);
153 await expect(async () => {
154 await act(() => {
155 root.render(<div ref={10} />);
156 });
157 // TODO: This throws an AggregateError. Need to update test infra to
158 // support matching against AggregateError.
159 }).rejects.toThrow();
160 await expect(async () => {
161 await act(() => {
162 root.render(<div ref={true} />);
163 });
164 // TODO: This throws an AggregateError. Need to update test infra to
165 // support matching against AggregateError.
166 }).rejects.toThrow();
167 await expect(async () => {
168 await act(() => {
169 root.render(<div ref={Symbol('foo')} />);
170 });
171 }).rejects.toThrow('Expected ref to be a function');
172 });
173 });
174
175 describe('root level refs', () => {
176 it('attaches and detaches root refs', async () => {
177 let inst = null;
178
179 // host node
180 let ref = jest.fn(value => (inst = value));
181 const container = document.createElement('div');
182 let root = ReactDOMClient.createRoot(container);
183 await act(() => {
184 root.render(<div ref={ref} />);
185 });
186 let result = container.firstChild;
187 expect(ref).toHaveBeenCalledTimes(1);
188 expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLDivElement);
189 expect(result).toBe(ref.mock.calls[0][0]);
190 await act(() => {
191 root.unmount();
192 });
193 expect(ref).toHaveBeenCalledTimes(2);
194 expect(ref.mock.calls[1][0]).toBe(null);
195
196 // composite
197 class Comp extends React.Component {
198 method() {
199 return true;
200 }
201 render() {
202 return <div>Comp</div>;
203 }
204 }
205
206 inst = null;
207 ref = jest.fn(value => (inst = value));
208 root = ReactDOMClient.createRoot(container);
209 await act(() => {
210 root.render(<Comp ref={ref} />);
211 });
212
213 expect(ref).toHaveBeenCalledTimes(1);
214 expect(inst).toBeInstanceOf(Comp);
215
216 // ensure we have the correct instance
217 expect(inst.method()).toBe(true);
218
219 await act(() => {
220 root.unmount();
221 });
222 expect(ref).toHaveBeenCalledTimes(2);
223 expect(ref.mock.calls[1][0]).toBe(null);
224
225 // fragment
226 inst = null;
227 ref = jest.fn(value => (inst = value));
228 let divInst = null;
229 const ref2 = jest.fn(value => (divInst = value));
230 root = ReactDOMClient.createRoot(container);
231 await act(() => {
232 root.render([
233 <Comp ref={ref} key="a" />,
234 5,
235 <div ref={ref2} key="b">
236 Hello
237 </div>,
238 ]);
239 });
240
241 // first call should be `Comp`
242 expect(ref).toHaveBeenCalledTimes(1);
243 expect(ref.mock.calls[0][0]).toBeInstanceOf(Comp);
244
245 expect(ref2).toHaveBeenCalledTimes(1);
246 expect(divInst).toBeInstanceOf(HTMLDivElement);
247
248 await act(() => {
249 root.unmount();
250 });
251 expect(ref).toHaveBeenCalledTimes(2);
252 expect(ref.mock.calls[1][0]).toBe(null);
253 expect(ref2).toHaveBeenCalledTimes(2);
254 expect(ref2.mock.calls[1][0]).toBe(null);
255
256 // null
257 root = ReactDOMClient.createRoot(container);
258 await act(() => {
259 root.render(null);
260 });
261 result = container.firstChild;
262 expect(result).toBe(null);
263
264 // primitives
265 await act(() => {
266 root.render(5);
267 });
268 result = container.firstChild;
269 expect(result).toBeInstanceOf(Text);
270 });
271 });
272
273 describe('refs return clean up function', () => {
274 it('calls clean up function if it exists', async () => {
275 const container = document.createElement('div');
276 let cleanUp = jest.fn();
277 let setup = jest.fn();
278
279 const root = ReactDOMClient.createRoot(container);
280
281 await act(() => {
282 root.render(
283 <div
284 ref={_ref => {
285 setup(_ref);
286 return cleanUp;
287 }}
288 />,
289 );
290 });
291
292 await act(() => {
293 root.render(
294 <div
295 ref={_ref => {
296 setup(_ref);
297 }}
298 />,
299 );
300 });
301
302 expect(setup).toHaveBeenCalledTimes(2);
303 expect(cleanUp).toHaveBeenCalledTimes(1);
304 expect(cleanUp.mock.calls[0][0]).toBe(undefined);
305
306 await act(() => {
307 root.render(<div ref={_ref => {}} />);
308 });
309
310 expect(cleanUp).toHaveBeenCalledTimes(1);
311 expect(setup).toHaveBeenCalledTimes(3);
312 expect(setup.mock.calls[2][0]).toBe(null);
313
314 cleanUp = jest.fn();
315 setup = jest.fn();
316
317 await act(() => {
318 root.render(
319 <div
320 ref={_ref => {
321 setup(_ref);
322 return cleanUp;
323 }}
324 />,
325 );
326 });
327
328 expect(setup).toHaveBeenCalledTimes(1);
329 expect(cleanUp).toHaveBeenCalledTimes(0);
330
331 await act(() => {
332 root.render(
333 <div
334 ref={_ref => {
335 setup(_ref);
336 return cleanUp;
337 }}
338 />,
339 );
340 });
341
342 expect(setup).toHaveBeenCalledTimes(2);
343 expect(cleanUp).toHaveBeenCalledTimes(1);
344 });
345
346 it('handles ref functions with stable identity', async () => {
347 const container = document.createElement('div');
348 const cleanUp = jest.fn();
349 const setup = jest.fn();
350
351 function _onRefChange(_ref) {
352 setup(_ref);
353 return cleanUp;
354 }
355
356 const root = ReactDOMClient.createRoot(container);
357 await act(() => {
358 root.render(<div ref={_onRefChange} />);
359 });
360
361 expect(setup).toHaveBeenCalledTimes(1);
362 expect(cleanUp).toHaveBeenCalledTimes(0);
363
364 await act(() => {
365 root.render(<div className="niceClassName" ref={_onRefChange} />);
366 });
367
368 expect(setup).toHaveBeenCalledTimes(1);
369 expect(cleanUp).toHaveBeenCalledTimes(0);
370
371 await act(() => {
372 root.render(<div />);
373 });
374
375 expect(setup).toHaveBeenCalledTimes(1);
376 expect(cleanUp).toHaveBeenCalledTimes(1);
377 });
378
379 it('handles detaching refs with either cleanup function or null argument', async () => {
380 const container = document.createElement('div');
381 const cleanUp = jest.fn();
382 const setup = jest.fn();
383 const setup2 = jest.fn();
384 const nullHandler = jest.fn();
385
386 function _onRefChangeWithCleanup(_ref) {
387 if (_ref) {
388 setup(_ref.id);
389 } else {
390 nullHandler();
391 }
392 return cleanUp;
393 }
394
395 function _onRefChangeWithoutCleanup(_ref) {
396 if (_ref) {
397 setup2(_ref.id);
398 } else {
399 nullHandler();
400 }
401 }
402
403 const root = ReactDOMClient.createRoot(container);
404 await act(() => {
405 root.render(<div id="test-div" ref={_onRefChangeWithCleanup} />);
406 });
407
408 expect(setup).toHaveBeenCalledWith('test-div');
409 expect(setup).toHaveBeenCalledTimes(1);
410 expect(cleanUp).toHaveBeenCalledTimes(0);
411
412 await act(() => {
413 root.render(<div id="test-div2" ref={_onRefChangeWithoutCleanup} />);
414 });
415
416 // Existing setup call was not called again
417 expect(setup).toHaveBeenCalledTimes(1);
418 // No null call because cleanup is returned
419 expect(nullHandler).toHaveBeenCalledTimes(0);
420 // Now we have a cleanup
421 expect(cleanUp).toHaveBeenCalledTimes(1);
422
423 // New ref is setup
424 expect(setup2).toHaveBeenCalledWith('test-div2');
425 expect(setup2).toHaveBeenCalledTimes(1);
426
427 // Now, render with the original ref again
428 await act(() => {
429 root.render(<div id="test-div3" ref={_onRefChangeWithCleanup} />);
430 });
431
432 // Setup was not called again
433 expect(setup2).toHaveBeenCalledWith('test-div2');
434 expect(setup2).toHaveBeenCalledTimes(1);
435
436 // Null handler hit because no cleanup is returned
437 expect(nullHandler).toHaveBeenCalledTimes(1);
438
439 // Original setup hit one more time
440 expect(setup).toHaveBeenCalledTimes(2);
441 });
442
443 it('calls cleanup function on unmount', async () => {
444 const container = document.createElement('div');
445 const cleanUp = jest.fn();
446 const setup = jest.fn();
447 const nullHandler = jest.fn();
448
449 function _onRefChangeWithCleanup(_ref) {
450 if (_ref) {
451 setup(_ref.id);
452 } else {
453 nullHandler();
454 }
455 return cleanUp;
456 }
457
458 const root = ReactDOMClient.createRoot(container);
459 await act(() => {
460 root.render(<div id="test-div" ref={_onRefChangeWithCleanup} />);
461 });
462
463 expect(setup).toHaveBeenCalledTimes(1);
464 expect(cleanUp).toHaveBeenCalledTimes(0);
465 expect(nullHandler).toHaveBeenCalledTimes(0);
466
467 root.unmount();
468
469 expect(setup).toHaveBeenCalledTimes(1);
470 // Now cleanup has been called
471 expect(cleanUp).toHaveBeenCalledTimes(1);
472 // Ref callback never called with null when cleanup is returned
473 expect(nullHandler).toHaveBeenCalledTimes(0);
474 });
475 });
476
477 describe('useImerativeHandle refs', () => {
478 const ImperativeHandleComponent = React.forwardRef(({name}, ref) => {
479 React.useImperativeHandle(
480 ref,
481 () => ({
482 greet() {
483 return `Hello ${name}`;
484 },
485 }),
486 [name],
487 );
488 return null;
489 });
490
491 it('should work with object style refs', async () => {
492 const container = document.createElement('div');
493 const root = ReactDOMClient.createRoot(container);
494 const ref = React.createRef();
495
496 await act(async () => {
497 root.render(<ImperativeHandleComponent name="Alice" ref={ref} />);
498 });
499 expect(ref.current.greet()).toBe('Hello Alice');
500 await act(() => {
501 root.render(null);
502 });
503 expect(ref.current).toBe(null);
504 });
505
506 it('should work with callback style refs', async () => {
507 const container = document.createElement('div');
508 const root = ReactDOMClient.createRoot(container);
509 let current = null;
510
511 await act(async () => {
512 root.render(
513 <ImperativeHandleComponent
514 name="Alice"
515 ref={r => {
516 current = r;
517 }}
518 />,
519 );
520 });
521 expect(current.greet()).toBe('Hello Alice');
522 await act(() => {
523 root.render(null);
524 });
525 expect(current).toBe(null);
526 });
527
528 it('should work with callback style refs with cleanup function', async () => {
529 const container = document.createElement('div');
530 const root = ReactDOMClient.createRoot(container);
531
532 let cleanupCalls = 0;
533 let createCalls = 0;
534 let current = null;
535
536 const ref = r => {
537 current = r;
538 createCalls++;
539 return () => {
540 current = null;
541 cleanupCalls++;
542 };
543 };
544
545 await act(async () => {
546 root.render(<ImperativeHandleComponent name="Alice" ref={ref} />);
547 });
548 expect(current.greet()).toBe('Hello Alice');
549 expect(createCalls).toBe(1);
550 expect(cleanupCalls).toBe(0);
551
552 // update a dep should recreate the ref
553 await act(async () => {
554 root.render(<ImperativeHandleComponent name="Bob" ref={ref} />);
555 });
556 expect(current.greet()).toBe('Hello Bob');
557 expect(createCalls).toBe(2);
558 expect(cleanupCalls).toBe(1);
559
560 // unmounting should call cleanup
561 await act(() => {
562 root.render(null);
563 });
564 expect(current).toBe(null);
565 expect(createCalls).toBe(2);
566 expect(cleanupCalls).toBe(2);
567 });
568 });