@samitouri / QOS-React-1 / commits / 04ee54cd12

[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 });