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 reactcore
8
+ */
9
+
10
+'use strict';
11
+
12
+let React;
13
+let ReactDOMClient;
14
+let act;
15
+let container;
16
+let Fragment;
17
+let Activity;
18
+
19
+describe('FragmentRefs', () => {
20
+ beforeEach(() => {
21
+ jest.resetModules();
22
+ React = require('react');
23
+ Fragment = React.Fragment;
24
+ Activity = React.unstable_Activity;
25
+ ReactDOMClient = require('react-dom/client');
26
+ act = require('internal-test-utils').act;
27
+ container = document.createElement('div');
28
+ document.body.appendChild(container);
29
+ });
30
+
31
+ afterEach(() => {
32
+ document.body.removeChild(container);
33
+ });
34
+
35
+ // @gate enableFragmentRefs
36
+ it('attaches a ref to Fragment', async () => {
37
+ const fragmentRef = React.createRef();
38
+ const root = ReactDOMClient.createRoot(container);
39
+
40
+ await act(() =>
41
+ root.render(
42
+ <div id="parent">
43
+ <Fragment ref={fragmentRef}>
44
+ <div id="child">Hi</div>
45
+ </Fragment>
46
+ </div>,
47
+ ),
48
+ );
49
+ expect(container.innerHTML).toEqual(
50
+ '<div id="parent"><div id="child">Hi</div></div>',
51
+ );
52
+
53
+ expect(fragmentRef.current).not.toBe(null);
54
+ });
55
+
56
+ // @gate enableFragmentRefs
57
+ it('accepts a ref callback', async () => {
58
+ let fragmentRef;
59
+ const root = ReactDOMClient.createRoot(container);
60
+
61
+ await act(() => {
62
+ root.render(
63
+ <Fragment ref={ref => (fragmentRef = ref)}>
64
+ <div id="child">Hi</div>
65
+ </Fragment>,
66
+ );
67
+ });
68
+
69
+ expect(fragmentRef._fragmentFiber).toBeTruthy();
70
+ });
71
+
72
+ // @gate enableFragmentRefs
73
+ it('is available in effects', async () => {
74
+ function Test() {
75
+ const fragmentRef = React.useRef(null);
76
+ React.useLayoutEffect(() => {
77
+ expect(fragmentRef.current).not.toBe(null);
78
+ });
79
+ React.useEffect(() => {
80
+ expect(fragmentRef.current).not.toBe(null);
81
+ });
82
+ return (
83
+ <Fragment ref={fragmentRef}>
84
+ <div />
85
+ </Fragment>
86
+ );
87
+ }
88
+
89
+ const root = ReactDOMClient.createRoot(container);
90
+ await act(() => root.render(<Test />));
91
+ });
92
+
93
+ describe('focus()', () => {
94
+ // @gate enableFragmentRefs
95
+ it('focuses the first focusable child', async () => {
96
+ const fragmentRef = React.createRef();
97
+ const root = ReactDOMClient.createRoot(container);
98
+
99
+ function Test() {
100
+ return (
101
+ <div>
102
+ <Fragment ref={fragmentRef}>
103
+ <div id="child-a" />
104
+ <style>{`#child-c {}`}</style>
105
+ <a id="child-b" href="/">
106
+ B
107
+ </a>
108
+ <a id="child-c" href="/">
109
+ C
110
+ </a>
111
+ </Fragment>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ await act(() => {
117
+ root.render(<Test />);
118
+ });
119
+
120
+ await act(() => {
121
+ fragmentRef.current.focus();
122
+ });
123
+ expect(document.activeElement.id).toEqual('child-b');
124
+ document.activeElement.blur();
125
+ });
126
+
127
+ // @gate enableFragmentRefs
128
+ it('preserves document order when adding and removing children', async () => {
129
+ const fragmentRef = React.createRef();
130
+ const root = ReactDOMClient.createRoot(container);
131
+
132
+ function Test({showA, showB}) {
133
+ return (
134
+ <Fragment ref={fragmentRef}>
135
+ {showA && <a href="/" id="child-a" />}
136
+ {showB && <a href="/" id="child-b" />}
137
+ </Fragment>
138
+ );
139
+ }
140
+
141
+ // Render with A as the first focusable child
142
+ await act(() => {
143
+ root.render(<Test showA={true} showB={false} />);
144
+ });
145
+ await act(() => {
146
+ fragmentRef.current.focus();
147
+ });
148
+ expect(document.activeElement.id).toEqual('child-a');
149
+ document.activeElement.blur();
150
+ // A is still the first focusable child, but B is also tracked
151
+ await act(() => {
152
+ root.render(<Test showA={true} showB={true} />);
153
+ });
154
+ await act(() => {
155
+ fragmentRef.current.focus();
156
+ });
157
+ expect(document.activeElement.id).toEqual('child-a');
158
+ document.activeElement.blur();
159
+
160
+ // B is now the first focusable child
161
+ await act(() => {
162
+ root.render(<Test showA={false} showB={true} />);
163
+ });
164
+ await act(() => {
165
+ fragmentRef.current.focus();
166
+ });
167
+ expect(document.activeElement.id).toEqual('child-b');
168
+ document.activeElement.blur();
169
+ });
170
+ });
171
+
172
+ describe('event listeners', () => {
173
+ // @gate enableFragmentRefs
174
+ it('adds and removes event listeners from children', async () => {
175
+ const parentRef = React.createRef();
176
+ const fragmentRef = React.createRef();
177
+ const childARef = React.createRef();
178
+ const childBRef = React.createRef();
179
+ const root = ReactDOMClient.createRoot(container);
180
+
181
+ let logs = [];
182
+
183
+ function handleFragmentRefClicks() {
184
+ logs.push('fragmentRef');
185
+ }
186
+
187
+ function Test() {
188
+ React.useEffect(() => {
189
+ fragmentRef.current.addEventListener(
190
+ 'click',
191
+ handleFragmentRefClicks,
192
+ );
193
+
194
+ return () => {
195
+ fragmentRef.current.removeEventListener(
196
+ 'click',
197
+ handleFragmentRefClicks,
198
+ );
199
+ };
200
+ }, []);
201
+ return (
202
+ <div ref={parentRef}>
203
+ <Fragment ref={fragmentRef}>
204
+ <>Text</>
205
+ <div ref={childARef}>A</div>
206
+ <>
207
+ <div ref={childBRef}>B</div>
208
+ </>
209
+ </Fragment>
210
+ </div>
211
+ );
212
+ }
213
+
214
+ await act(() => {
215
+ root.render(<Test />);
216
+ });
217
+
218
+ childARef.current.addEventListener('click', () => {
219
+ logs.push('A');
220
+ });
221
+
222
+ childBRef.current.addEventListener('click', () => {
223
+ logs.push('B');
224
+ });
225
+
226
+ // Clicking on the parent should not trigger any listeners
227
+ parentRef.current.click();
228
+ expect(logs).toEqual([]);
229
+
230
+ // Clicking a child triggers its own listeners and the Fragment's
231
+ childARef.current.click();
232
+ expect(logs).toEqual(['fragmentRef', 'A']);
233
+
234
+ logs = [];
235
+
236
+ childBRef.current.click();
237
+ expect(logs).toEqual(['fragmentRef', 'B']);
238
+
239
+ logs = [];
240
+
241
+ fragmentRef.current.removeEventListener('click', handleFragmentRefClicks);
242
+
243
+ childARef.current.click();
244
+ expect(logs).toEqual(['A']);
245
+
246
+ logs = [];
247
+
248
+ childBRef.current.click();
249
+ expect(logs).toEqual(['B']);
250
+ });
251
+
252
+ // @gate enableFragmentRefs
253
+ it('adds and removes event listeners from children with multiple fragments', async () => {
254
+ const fragmentRef = React.createRef();
255
+ const nestedFragmentRef = React.createRef();
256
+ const nestedFragmentRef2 = React.createRef();
257
+ const childARef = React.createRef();
258
+ const childBRef = React.createRef();
259
+ const childCRef = React.createRef();
260
+ const root = ReactDOMClient.createRoot(container);
261
+
262
+ await act(() => {
263
+ root.render(
264
+ <div>
265
+ <Fragment ref={fragmentRef}>
266
+ <div ref={childARef}>A</div>
267
+ <div>
268
+ <Fragment ref={nestedFragmentRef}>
269
+ <div ref={childBRef}>B</div>
270
+ </Fragment>
271
+ </div>
272
+ <Fragment ref={nestedFragmentRef2}>
273
+ <div ref={childCRef}>C</div>
274
+ </Fragment>
275
+ </Fragment>
276
+ </div>,
277
+ );
278
+ });
279
+
280
+ let logs = [];
281
+
282
+ function handleFragmentRefClicks() {
283
+ logs.push('fragmentRef');
284
+ }
285
+
286
+ function handleNestedFragmentRefClicks() {
287
+ logs.push('nestedFragmentRef');
288
+ }
289
+
290
+ function handleNestedFragmentRef2Clicks() {
291
+ logs.push('nestedFragmentRef2');
292
+ }
293
+
294
+ fragmentRef.current.addEventListener('click', handleFragmentRefClicks);
295
+ nestedFragmentRef.current.addEventListener(
296
+ 'click',
297
+ handleNestedFragmentRefClicks,
298
+ );
299
+ nestedFragmentRef2.current.addEventListener(
300
+ 'click',
301
+ handleNestedFragmentRef2Clicks,
302
+ );
303
+
304
+ childBRef.current.click();
305
+ // Event bubbles to the parent fragment
306
+ expect(logs).toEqual(['nestedFragmentRef', 'fragmentRef']);
307
+
308
+ logs = [];
309
+
310
+ childARef.current.click();
311
+ expect(logs).toEqual(['fragmentRef']);
312
+
313
+ logs = [];
314
+ childCRef.current.click();
315
+ expect(logs).toEqual(['fragmentRef', 'nestedFragmentRef2']);
316
+
317
+ logs = [];
318
+
319
+ fragmentRef.current.removeEventListener('click', handleFragmentRefClicks);
320
+ nestedFragmentRef.current.removeEventListener(
321
+ 'click',
322
+ handleNestedFragmentRefClicks,
323
+ );
324
+ childCRef.current.click();
325
+ expect(logs).toEqual(['nestedFragmentRef2']);
326
+ });
327
+
328
+ // @gate enableFragmentRefs
329
+ it('adds an event listener to a newly added child', async () => {
330
+ const fragmentRef = React.createRef();
331
+ const childRef = React.createRef();
332
+ const root = ReactDOMClient.createRoot(container);
333
+ let showChild;
334
+
335
+ function Component() {
336
+ const [shouldShowChild, setShouldShowChild] = React.useState(false);
337
+ showChild = () => {
338
+ setShouldShowChild(true);
339
+ };
340
+
341
+ return (
342
+ <div>
343
+ <Fragment ref={fragmentRef}>
344
+ <div id="a">A</div>
345
+ {shouldShowChild && (
346
+ <div ref={childRef} id="b">
347
+ B
348
+ </div>
349
+ )}
350
+ </Fragment>
351
+ </div>
352
+ );
353
+ }
354
+
355
+ await act(() => {
356
+ root.render(<Component />);
357
+ });
358
+
359
+ expect(fragmentRef.current).not.toBe(null);
360
+ expect(childRef.current).toBe(null);
361
+
362
+ let hasClicked = false;
363
+ fragmentRef.current.addEventListener('click', () => {
364
+ hasClicked = true;
365
+ });
366
+
367
+ await act(() => {
368
+ showChild();
369
+ });
370
+ expect(childRef.current).not.toBe(null);
371
+
372
+ childRef.current.click();
373
+ expect(hasClicked).toBe(true);
374
+ });
375
+
376
+ // @gate enableFragmentRefs
377
+ it('applies event listeners to host children nested within non-host children', async () => {
378
+ const fragmentRef = React.createRef();
379
+ const childRef = React.createRef();
380
+ const nestedChildRef = React.createRef();
381
+ const root = ReactDOMClient.createRoot(container);
382
+
383
+ function Wrapper({children}) {
384
+ return children;
385
+ }
386
+
387
+ await act(() => {
388
+ root.render(
389
+ <div>
390
+ <Fragment ref={fragmentRef}>
391
+ <div ref={childRef}>Host A</div>
392
+ <Wrapper>
393
+ <Wrapper>
394
+ <Wrapper>
395
+ <div ref={nestedChildRef}>Host B</div>
396
+ </Wrapper>
397
+ </Wrapper>
398
+ </Wrapper>
399
+ </Fragment>
400
+ </div>,
401
+ );
402
+ });
403
+ const logs = [];
404
+ fragmentRef.current.addEventListener('click', e => {
405
+ logs.push(e.target.textContent);
406
+ });
407
+
408
+ expect(logs).toEqual([]);
409
+ childRef.current.click();
410
+ expect(logs).toEqual(['Host A']);
411
+ nestedChildRef.current.click();
412
+ expect(logs).toEqual(['Host A', 'Host B']);
413
+ });
414
+
415
+ // @gate enableFragmentRefs
416
+ it('allows adding and cleaning up listeners in effects', async () => {
417
+ const root = ReactDOMClient.createRoot(container);
418
+
419
+ let logs = [];
420
+ function logClick(e) {
421
+ logs.push(e.currentTarget.id);
422
+ }
423
+
424
+ let rerender;
425
+ let removeEventListeners;
426
+
427
+ function Test() {
428
+ const fragmentRef = React.useRef(null);
429
+ // eslint-disable-next-line no-unused-vars
430
+ const [_, setState] = React.useState(0);
431
+ rerender = () => {
432
+ setState(p => p + 1);
433
+ };
434
+ removeEventListeners = () => {
435
+ fragmentRef.current.removeEventListener('click', logClick);
436
+ };
437
+ React.useEffect(() => {
438
+ fragmentRef.current.addEventListener('click', logClick);
439
+
440
+ return removeEventListeners;
441
+ });
442
+
443
+ return (
444
+ <Fragment ref={fragmentRef}>
445
+ <div id="child-a" />
446
+ </Fragment>
447
+ );
448
+ }
449
+
450
+ // The event listener was applied
451
+ await act(() => root.render(<Test />));
452
+ expect(logs).toEqual([]);
453
+ document.querySelector('#child-a').click();
454
+ expect(logs).toEqual(['child-a']);
455
+
456
+ // The event listener can be removed and re-added
457
+ logs = [];
458
+ await act(rerender);
459
+ document.querySelector('#child-a').click();
460
+ expect(logs).toEqual(['child-a']);
461
+ });
462
+
463
+ // @gate enableFragmentRefs
464
+ it('does not apply removed event listeners to new children', async () => {
465
+ const root = ReactDOMClient.createRoot(container);
466
+ const fragmentRef = React.createRef(null);
467
+ function Test() {
468
+ return (
469
+ <Fragment ref={fragmentRef}>
470
+ <div id="child-a" />
471
+ </Fragment>
472
+ );
473
+ }
474
+
475
+ let logs = [];
476
+ function logClick(e) {
477
+ logs.push(e.currentTarget.id);
478
+ }
479
+ await act(() => {
480
+ root.render(<Test />);
481
+ });
482
+ fragmentRef.current.addEventListener('click', logClick);
483
+ const childA = document.querySelector('#child-a');
484
+ childA.click();
485
+ expect(logs).toEqual(['child-a']);
486
+
487
+ logs = [];
488
+ fragmentRef.current.removeEventListener('click', logClick);
489
+ childA.click();
490
+ expect(logs).toEqual([]);
491
+ });
492
+
493
+ describe('with activity', () => {
494
+ // @gate enableFragmentRefs && enableActivity
495
+ it('does not apply event listeners to hidden trees', async () => {
496
+ const parentRef = React.createRef();
497
+ const fragmentRef = React.createRef();
498
+ const root = ReactDOMClient.createRoot(container);
499
+
500
+ function Test() {
501
+ return (
502
+ <div ref={parentRef}>
503
+ <Fragment ref={fragmentRef}>
504
+ <div>Child 1</div>
505
+ <Activity mode="hidden">
506
+ <div>Child 2</div>
507
+ </Activity>
508
+ <div>Child 3</div>
509
+ </Fragment>
510
+ </div>
511
+ );
512
+ }
513
+
514
+ await act(() => {
515
+ root.render(<Test />);
516
+ });
517
+
518
+ const logs = [];
519
+ fragmentRef.current.addEventListener('click', e => {
520
+ logs.push(e.target.textContent);
521
+ });
522
+
523
+ const [child1, child2, child3] = parentRef.current.children;
524
+ child1.click();
525
+ child2.click();
526
+ child3.click();
527
+ expect(logs).toEqual(['Child 1', 'Child 3']);
528
+ });
529
+
530
+ // @gate enableFragmentRefs && enableActivity
531
+ it('applies event listeners to visible trees', async () => {
532
+ const parentRef = React.createRef();
533
+ const fragmentRef = React.createRef();
534
+ const root = ReactDOMClient.createRoot(container);
535
+
536
+ function Test() {
537
+ return (
538
+ <div ref={parentRef}>
539
+ <Fragment ref={fragmentRef}>
540
+ <div>Child 1</div>
541
+ <Activity mode="visible">
542
+ <div>Child 2</div>
543
+ </Activity>
544
+ <div>Child 3</div>
545
+ </Fragment>
546
+ </div>
547
+ );
548
+ }
549
+
550
+ await act(() => {
551
+ root.render(<Test />);
552
+ });
553
+
554
+ const logs = [];
555
+ fragmentRef.current.addEventListener('click', e => {
556
+ logs.push(e.target.textContent);
557
+ });
558
+
559
+ const [child1, child2, child3] = parentRef.current.children;
560
+ child1.click();
561
+ child2.click();
562
+ child3.click();
563
+ expect(logs).toEqual(['Child 1', 'Child 2', 'Child 3']);
564
+ });
565
+
566
+ // @gate enableFragmentRefs && enableActivity
567
+ it('handles Activity modes switching', async () => {
568
+ const fragmentRef = React.createRef();
569
+ const fragmentRef2 = React.createRef();
570
+ const parentRef = React.createRef();
571
+ const root = ReactDOMClient.createRoot(container);
572
+
573
+ function Test({mode}) {
574
+ return (
575
+ <div id="parent" ref={parentRef}>
576
+ <Fragment ref={fragmentRef}>
577
+ <Activity mode={mode}>
578
+ <div id="child1">Child</div>
579
+ <Fragment ref={fragmentRef2}>
580
+ <div id="child2">Child 2</div>
581
+ </Fragment>
582
+ </Activity>
583
+ </Fragment>
584
+ </div>
585
+ );
586
+ }
587
+
588
+ await act(() => {
589
+ root.render(<Test mode="visible" />);
590
+ });
591
+
592
+ let logs = [];
593
+ fragmentRef.current.addEventListener('click', () => {
594
+ logs.push('clicked 1');
595
+ });
596
+ fragmentRef2.current.addEventListener('click', () => {
597
+ logs.push('clicked 2');
598
+ });
599
+ parentRef.current.lastChild.click();
600
+ expect(logs).toEqual(['clicked 1', 'clicked 2']);
601
+
602
+ logs = [];
603
+ await act(() => {
604
+ root.render(<Test mode="hidden" />);
605
+ });
606
+ parentRef.current.firstChild.click();
607
+ parentRef.current.lastChild.click();
608
+ expect(logs).toEqual([]);
609
+
610
+ logs = [];
611
+ await act(() => {
612
+ root.render(<Test mode="visible" />);
613
+ });
614
+ parentRef.current.lastChild.click();
615
+ // Event order is flipped here because the nested child re-registers first
616
+ expect(logs).toEqual(['clicked 2', 'clicked 1']);
617
+ });
618
+ });
619
+ });
620
+});