[tests] add more portal activity tests (#35095)
I copied some tests from [`Activity-test.js`](https://github.com/facebook/react/blob/1d68bce19c9409ed70604d1d16b70b68ce71dc4a/packages/react-reconciler/src/__tests__/Activity-test.js) and made them portal specific just to confirm my understanding of how Portals + Activity interact is correct. Seems good to include them.
Ricky committed
Nov 11, 2025 at 12:47 UTC
04ee54cd128a48cb3fdac7256e1a45d6d9743d8c
1 file changed
+436
-5
packages/react-dom/src/__tests__/ReactDOMActivity-test.js
+436
-5
@@ -10,11 +10,17 @@
10
'use strict';
11
12
let React;
13
-let Activity;
14
-let useState;
13
let ReactDOM;
14
let ReactDOMClient;
15
+let Scheduler;
16
let act;
17
+let Activity;
18
+let useState;
19
+let useLayoutEffect;
20
+let useEffect;
21
+let LegacyHidden;
22
+let assertLog;
23
+let Suspense;
24
25
describe('ReactDOMActivity', () => {
26
let container;
@@ -22,11 +28,19 @@ describe('ReactDOMActivity', () => {
28
beforeEach(() => {
29
jest.resetModules();
30
React = require('react');
31
+ Scheduler = require('scheduler/unstable_mock');
32
Activity = React.Activity;
33
useState = React.useState;
34
+ Suspense = React.Suspense;
35
+ useState = React.useState;
36
+ LegacyHidden = React.unstable_LegacyHidden;
37
+ useLayoutEffect = React.useLayoutEffect;
38
+ useEffect = React.useEffect;
39
ReactDOM = require('react-dom');
40
ReactDOMClient = require('react-dom/client');
29
- act = require('internal-test-utils').act;
41
+ const InternalTestUtils = require('internal-test-utils');
42
+ act = InternalTestUtils.act;
43
+ assertLog = InternalTestUtils.assertLog;
44
container = document.createElement('div');
45
document.body.appendChild(container);
46
});
@@ -35,6 +49,11 @@ describe('ReactDOMActivity', () => {
49
document.body.removeChild(container);
50
});
51
52
+ function Text(props) {
53
+ Scheduler.log(props.text);
54
+ return <span prop={props.text}>{props.children}</span>;
55
+ }
56
+
57
// @gate enableActivity
58
it(
59
'hiding an Activity boundary also hides the direct children of any ' +
@@ -53,7 +72,7 @@ describe('ReactDOMActivity', () => {
72
);
73
}
74
56
- function App({portalContents}) {
75
+ function App() {
76
return (
77
<Accordion>
78
<div>
@@ -99,7 +118,7 @@ describe('ReactDOMActivity', () => {
118
);
119
}
120
102
- function App({portalContents}) {
121
+ function App() {
122
return (
123
<Activity mode="hidden">
124
<div>
@@ -131,4 +150,416 @@ describe('ReactDOMActivity', () => {
150
);
151
},
152
);
153
+
154
+ // @gate enableActivity
155
+ it('hides new portals added to an already hidden tree', async () => {
156
+ function Child() {
157
+ return <Text text="Child" />;
158
+ }
159
+
160
+ const portalContainer = document.createElement('div');
161
+
162
+ function Portal({children}) {
163
+ return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
164
+ }
165
+
166
+ const root = ReactDOMClient.createRoot(container);
167
+ // Mount hidden tree.
168
+ await act(() => {
169
+ root.render(
170
+ <Activity mode="hidden">
171
+ <Text text="Parent" />
172
+ </Activity>,
173
+ );
174
+ });
175
+ assertLog(['Parent']);
176
+ expect(container.innerHTML).toBe(
177
+ '<span prop="Parent" style="display: none;"></span>',
178
+ );
179
+ expect(portalContainer.innerHTML).toBe('');
180
+
181
+ // Add a portal inside the hidden tree.
182
+ await act(() => {
183
+ root.render(
184
+ <Activity mode="hidden">
185
+ <Text text="Parent" />
186
+ <Portal>
187
+ <Child />
188
+ </Portal>
189
+ </Activity>,
190
+ );
191
+ });
192
+ assertLog(['Parent', 'Child']);
193
+ expect(container.innerHTML).toBe(
194
+ '<span prop="Parent" style="display: none;"></span><div style="display: none;"></div>',
195
+ );
196
+ expect(portalContainer.innerHTML).toBe(
197
+ '<span prop="Child" style="display: none;"></span>',
198
+ );
199
+
200
+ // Now reveal it.
201
+ await act(() => {
202
+ root.render(
203
+ <Activity mode="visible">
204
+ <Text text="Parent" />
205
+ <Portal>
206
+ <Child />
207
+ </Portal>
208
+ </Activity>,
209
+ );
210
+ });
211
+
212
+ assertLog(['Parent', 'Child']);
213
+ expect(container.innerHTML).toBe(
214
+ '<span prop="Parent" style=""></span><div style=""></div>',
215
+ );
216
+ expect(portalContainer.innerHTML).toBe(
217
+ '<span prop="Child" style=""></span>',
218
+ );
219
+ });
220
+
221
+ // @gate enableActivity
222
+ it('hides new insertions inside an already hidden portal', async () => {
223
+ function Child({text}) {
224
+ useLayoutEffect(() => {
225
+ Scheduler.log(`Mount layout ${text}`);
226
+ return () => {
227
+ Scheduler.log(`Unmount layout ${text}`);
228
+ };
229
+ }, [text]);
230
+ return <Text text={text} />;
231
+ }
232
+
233
+ const portalContainer = document.createElement('div');
234
+
235
+ function Portal({children}) {
236
+ return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
237
+ }
238
+
239
+ const root = ReactDOMClient.createRoot(container);
240
+ // Mount hidden tree.
241
+ await act(() => {
242
+ root.render(
243
+ <Activity mode="hidden">
244
+ <Portal>
245
+ <Child text="A" />
246
+ </Portal>
247
+ </Activity>,
248
+ );
249
+ });
250
+ assertLog(['A']);
251
+ expect(container.innerHTML).toBe('<div style="display: none;"></div>');
252
+ expect(portalContainer.innerHTML).toBe(
253
+ '<span prop="A" style="display: none;"></span>',
254
+ );
255
+
256
+ // Add a node inside the hidden portal.
257
+ await act(() => {
258
+ root.render(
259
+ <Activity mode="hidden">
260
+ <Portal>
261
+ <Child text="A" />
262
+ <Child text="B" />
263
+ </Portal>
264
+ </Activity>,
265
+ );
266
+ });
267
+ assertLog(['A', 'B']);
268
+ expect(container.innerHTML).toBe('<div style="display: none;"></div>');
269
+ expect(portalContainer.innerHTML).toBe(
270
+ '<span prop="A" style="display: none;"></span><span prop="B" style="display: none;"></span>',
271
+ );
272
+
273
+ // Now reveal it.
274
+ await act(() => {
275
+ root.render(
276
+ <Activity mode="visible">
277
+ <Portal>
278
+ <Child text="A" />
279
+ <Child text="B" />
280
+ </Portal>
281
+ </Activity>,
282
+ );
283
+ });
284
+
285
+ assertLog(['A', 'B', 'Mount layout A', 'Mount layout B']);
286
+ expect(container.innerHTML).toBe('<div style=""></div>');
287
+ expect(portalContainer.innerHTML).toBe(
288
+ '<span prop="A" style=""></span><span prop="B" style=""></span>',
289
+ );
290
+ });
291
+
292
+ // @gate enableActivity
293
+ it('reveal an inner Suspense boundary without revealing an outer Activity on the same host child', async () => {
294
+ const promise = new Promise(() => {});
295
+
296
+ function Child({showInner}) {
297
+ useLayoutEffect(() => {
298
+ Scheduler.log('Mount layout');
299
+ return () => {
300
+ Scheduler.log('Unmount layout');
301
+ };
302
+ }, []);
303
+ return (
304
+ <>
305
+ {showInner ? null : promise}
306
+ <Text text="Child" />
307
+ </>
308
+ );
309
+ }
310
+
311
+ const portalContainer = document.createElement('div');
312
+
313
+ function Portal({children}) {
314
+ return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
315
+ }
316
+
317
+ const root = ReactDOMClient.createRoot(container);
318
+
319
+ // Prerender the whole tree.
320
+ await act(() => {
321
+ root.render(
322
+ <Activity mode="hidden">
323
+ <Portal>
324
+ <Suspense name="Inner" fallback={<span>Loading</span>}>
325
+ <Child showInner={true} />
326
+ </Suspense>
327
+ </Portal>
328
+ </Activity>,
329
+ );
330
+ });
331
+
332
+ assertLog(['Child']);
333
+ expect(container.innerHTML).toBe('<div style="display: none;"></div>');
334
+ expect(portalContainer.innerHTML).toBe(
335
+ '<span prop="Child" style="display: none;"></span>',
336
+ );
337
+
338
+ // Re-suspend the inner.
339
+ await act(() => {
340
+ root.render(
341
+ <Activity mode="hidden">
342
+ <Portal>
343
+ <Suspense name="Inner" fallback={<span>Loading</span>}>
344
+ <Child showInner={false} />
345
+ </Suspense>
346
+ </Portal>
347
+ </Activity>,
348
+ );
349
+ });
350
+ assertLog([]);
351
+ expect(container.innerHTML).toBe('<div style="display: none;"></div>');
352
+ expect(portalContainer.innerHTML).toBe(
353
+ '<span prop="Child" style="display: none;"></span><span style="display: none;">Loading</span>',
354
+ );
355
+
356
+ // Toggle to visible while suspended.
357
+ await act(() => {
358
+ root.render(
359
+ <Activity mode="visible">
360
+ <Portal>
361
+ <Suspense name="Inner" fallback={<span>Loading</span>}>
362
+ <Child showInner={false} />
363
+ </Suspense>
364
+ </Portal>
365
+ </Activity>,
366
+ );
367
+ });
368
+ assertLog([]);
369
+ expect(container.innerHTML).toBe('<div style=""></div>');
370
+ expect(portalContainer.innerHTML).toBe(
371
+ '<span prop="Child" style="display: none;"></span><span style="">Loading</span>',
372
+ );
373
+
374
+ // Now reveal.
375
+ await act(() => {
376
+ root.render(
377
+ <Activity mode="visible">
378
+ <Portal>
379
+ <Suspense name="Inner" fallback={<span>Loading</span>}>
380
+ <Child showInner={true} />
381
+ </Suspense>
382
+ </Portal>
383
+ </Activity>,
384
+ );
385
+ });
386
+ assertLog(['Child', 'Mount layout']);
387
+ expect(container.innerHTML).toBe('<div style=""></div>');
388
+ expect(portalContainer.innerHTML).toBe(
389
+ '<span prop="Child" style=""></span>',
390
+ );
391
+ });
392
+
393
+ // @gate enableActivity
394
+ it('mounts/unmounts layout effects in portal when visibility changes (starting visible)', async () => {
395
+ function Child() {
396
+ useLayoutEffect(() => {
397
+ Scheduler.log('Mount layout');
398
+ return () => {
399
+ Scheduler.log('Unmount layout');
400
+ };
401
+ }, []);
402
+ return <Text text="Child" />;
403
+ }
404
+
405
+ const portalContainer = document.createElement('div');
406
+
407
+ function Portal({children}) {
408
+ return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
409
+ }
410
+
411
+ const root = ReactDOMClient.createRoot(container);
412
+ // Mount visible tree.
413
+ await act(() => {
414
+ root.render(
415
+ <Activity mode="visible">
416
+ <Portal>
417
+ <Child />
418
+ </Portal>
419
+ </Activity>,
420
+ );
421
+ });
422
+ assertLog(['Child', 'Mount layout']);
423
+ expect(container.innerHTML).toBe('<div></div>');
424
+ expect(portalContainer.innerHTML).toBe('<span prop="Child"></span>');
425
+
426
+ // Hide the tree. The layout effect is unmounted.
427
+ await act(() => {
428
+ root.render(
429
+ <Activity mode="hidden">
430
+ <Portal>
431
+ <Child />
432
+ </Portal>
433
+ </Activity>,
434
+ );
435
+ });
436
+ assertLog(['Unmount layout', 'Child']);
437
+ expect(container.innerHTML).toBe('<div style="display: none;"></div>');
438
+ expect(portalContainer.innerHTML).toBe(
439
+ '<span prop="Child" style="display: none;"></span>',
440
+ );
441
+ });
442
+
443
+ // @gate enableActivity
444
+ it('mounts/unmounts layout effects in portal when visibility changes (starting hidden)', async () => {
445
+ function Child() {
446
+ useLayoutEffect(() => {
447
+ Scheduler.log('Mount layout');
448
+ return () => {
449
+ Scheduler.log('Unmount layout');
450
+ };
451
+ }, []);
452
+ return <Text text="Child" />;
453
+ }
454
+
455
+ const portalContainer = document.createElement('div');
456
+
457
+ function Portal({children}) {
458
+ return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
459
+ }
460
+
461
+ const root = ReactDOMClient.createRoot(container);
462
+ // Mount hidden tree.
463
+ await act(() => {
464
+ root.render(
465
+ <Activity mode="hidden">
466
+ <Portal>
467
+ <Child />
468
+ </Portal>
469
+ </Activity>,
470
+ );
471
+ });
472
+ // No layout effect.
473
+ assertLog(['Child']);
474
+ expect(container.innerHTML).toBe('<div style="display: none;"></div>');
475
+ expect(portalContainer.innerHTML).toBe(
476
+ '<span prop="Child" style="display: none;"></span>',
477
+ );
478
+
479
+ // Unhide the tree. The layout effect is mounted.
480
+ await act(() => {
481
+ root.render(
482
+ <Activity mode="visible">
483
+ <Portal>
484
+ <Child />
485
+ </Portal>
486
+ </Activity>,
487
+ );
488
+ });
489
+ assertLog(['Child', 'Mount layout']);
490
+ expect(container.innerHTML).toBe('<div style=""></div>');
491
+ expect(portalContainer.innerHTML).toBe(
492
+ '<span prop="Child" style=""></span>',
493
+ );
494
+ });
495
+
496
+ // @gate enableLegacyHidden
497
+ it('does not toggle effects or hide nodes for LegacyHidden component inside portal', async () => {
498
+ function Child() {
499
+ useLayoutEffect(() => {
500
+ Scheduler.log('Mount layout');
501
+ return () => {
502
+ Scheduler.log('Unmount layout');
503
+ };
504
+ }, []);
505
+ useEffect(() => {
506
+ Scheduler.log('Mount passive');
507
+ return () => {
508
+ Scheduler.log('Unmount passive');
509
+ };
510
+ }, []);
511
+ return <Text text="Child" />;
512
+ }
513
+
514
+ const portalContainer = document.createElement('div');
515
+
516
+ function Portal({children}) {
517
+ return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
518
+ }
519
+
520
+ const root = ReactDOMClient.createRoot(container);
521
+ // Mount visible tree.
522
+ await act(() => {
523
+ root.render(
524
+ <LegacyHidden mode="visible">
525
+ <Portal>
526
+ <Child />
527
+ </Portal>
528
+ </LegacyHidden>,
529
+ );
530
+ });
531
+ assertLog(['Child', 'Mount layout', 'Mount passive']);
532
+ expect(container.innerHTML).toBe('<div></div>');
533
+ expect(portalContainer.innerHTML).toBe('<span prop="Child"></span>');
534
+
535
+ // Hide the tree.
536
+ await act(() => {
537
+ root.render(
538
+ <LegacyHidden mode="hidden">
539
+ <Portal>
540
+ <Child />
541
+ </Portal>
542
+ </LegacyHidden>,
543
+ );
544
+ });
545
+ // Effects not unmounted.
546
+ assertLog(['Child']);
547
+ expect(container.innerHTML).toBe('<div></div>');
548
+ expect(portalContainer.innerHTML).toBe('<span prop="Child"></span>');
549
+
550
+ // Unhide the tree.
551
+ await act(() => {
552
+ root.render(
553
+ <LegacyHidden mode="visible">
554
+ <Portal>
555
+ <Child />
556
+ </Portal>
557
+ </LegacyHidden>,
558
+ );
559
+ });
560
+ // Effects already mounted.
561
+ assertLog(['Child']);
562
+ expect(container.innerHTML).toBe('<div></div>');
563
+ expect(portalContainer.innerHTML).toBe('<span prop="Child"></span>');
564
+ });
565
});