@samitouri / QOS-React-1 / commits / ed69815ceb

[DevTools] feat: display subtree for Activity and dim in hidden mode (#36094)

With this change, Components panel will display subtree of the Activity. When it is in hidden mode, the subtree will be dimmed. Added Jest tests and a sandbox case to `react-devtools-shell`. Demo: https://github.com/user-attachments/assets/69a2e8d6-585d-4fcd-b57e-e9ae06d0a1b3

Ruslan Lesiutin committed Mar 23, 2026 at 14:29 UTC ed69815cebae33b0326cc69faa90f813bb924f3b
9 files changed +499 -30
packages/react-devtools-shared/src/__tests__/store-test.js
+271 -6
@@ -297,6 +297,269 @@ describe('Store', () => {
297 });
298 });
299
300 + describe('Activity hidden state', () => {
301 + // @reactVersion >= 19
302 + it('should mark Activity subtree elements as hidden when mode is hidden', async () => {
303 + const Activity = React.Activity || React.unstable_Activity;
304 +
305 + function Child() {
306 + return <div>child</div>;
307 + }
308 +
309 + function App({hidden}) {
310 + return (
311 + <Activity mode={hidden ? 'hidden' : 'visible'}>
312 + <Child />
313 + </Activity>
314 + );
315 + }
316 +
317 + await actAsync(() => {
318 + render(<App hidden={true} />);
319 + });
320 +
321 + // Activity element should be marked as hidden and collapsed
322 + const activityElement = store.getElementAtIndex(1);
323 + expect(activityElement.displayName).toBe('Activity');
324 + expect(activityElement.isActivityHidden).toBe(true);
325 + expect(activityElement.isInsideHiddenActivity).toBe(false);
326 + expect(activityElement.isCollapsed).toBe(true);
327 +
328 + // Expand to access children
329 + store.toggleIsCollapsed(activityElement.id, false);
330 +
331 + // Children should still be in the tree but marked as inside hidden Activity
332 + const childElement = store.getElementAtIndex(2);
333 + expect(childElement.displayName).toBe('Child');
334 + expect(childElement.isInsideHiddenActivity).toBe(true);
335 + });
336 +
337 + // @reactVersion >= 19
338 + it('should not mark Activity subtree as hidden when mode is visible', async () => {
339 + const Activity = React.Activity || React.unstable_Activity;
340 +
341 + function Child() {
342 + return <div>child</div>;
343 + }
344 +
345 + function App() {
346 + return (
347 + <Activity mode="visible">
348 + <Child />
349 + </Activity>
350 + );
351 + }
352 +
353 + await actAsync(() => {
354 + render(<App />);
355 + });
356 +
357 + const activityElement = store.getElementAtIndex(1);
358 + expect(activityElement.displayName).toBe('Activity');
359 + expect(activityElement.isActivityHidden).toBe(false);
360 + expect(activityElement.isInsideHiddenActivity).toBe(false);
361 + expect(activityElement.isCollapsed).toBe(false);
362 +
363 + const childElement = store.getElementAtIndex(2);
364 + expect(childElement.displayName).toBe('Child');
365 + expect(childElement.isInsideHiddenActivity).toBe(false);
366 + });
367 +
368 + // @reactVersion >= 19
369 + it('should update hidden state when Activity mode toggles', async () => {
370 + const Activity = React.Activity || React.unstable_Activity;
371 +
372 + function Child() {
373 + return <div>child</div>;
374 + }
375 +
376 + function App({hidden}) {
377 + return (
378 + <Activity mode={hidden ? 'hidden' : 'visible'}>
379 + <Child />
380 + </Activity>
381 + );
382 + }
383 +
384 + // Start visible
385 + await actAsync(() => {
386 + render(<App hidden={false} />);
387 + });
388 +
389 + let activityElement = store.getElementAtIndex(1);
390 + expect(activityElement.isActivityHidden).toBe(false);
391 + expect(activityElement.isCollapsed).toBe(false);
392 +
393 + let childElement = store.getElementAtIndex(2);
394 + expect(childElement.isInsideHiddenActivity).toBe(false);
395 +
396 + // Toggle to hidden — children remain but subtree collapses
397 + await actAsync(() => {
398 + render(<App hidden={true} />);
399 + });
400 +
401 + activityElement = store.getElementAtIndex(1);
402 + expect(activityElement.isActivityHidden).toBe(true);
403 + expect(activityElement.isCollapsed).toBe(true);
404 +
405 + // Expand to verify children are still marked
406 + store.toggleIsCollapsed(activityElement.id, false);
407 +
408 + childElement = store.getElementAtIndex(2);
409 + expect(childElement.displayName).toBe('Child');
410 + expect(childElement.isInsideHiddenActivity).toBe(true);
411 +
412 + // Toggle back to visible — subtree expands automatically
413 + await actAsync(() => {
414 + render(<App hidden={false} />);
415 + });
416 +
417 + activityElement = store.getElementAtIndex(1);
418 + expect(activityElement.isActivityHidden).toBe(false);
419 + expect(activityElement.isCollapsed).toBe(false);
420 +
421 + childElement = store.getElementAtIndex(2);
422 + expect(childElement.isInsideHiddenActivity).toBe(false);
423 + });
424 +
425 + // @reactVersion >= 19
426 + it('should propagate hidden state to deeply nested children', async () => {
427 + const Activity = React.Activity || React.unstable_Activity;
428 +
429 + function GrandChild() {
430 + return <div>grandchild</div>;
431 + }
432 + function Child() {
433 + return <GrandChild />;
434 + }
435 +
436 + function App({hidden}) {
437 + return (
438 + <Activity mode={hidden ? 'hidden' : 'visible'}>
439 + <Child />
440 + </Activity>
441 + );
442 + }
443 +
444 + await actAsync(() => {
445 + render(<App hidden={true} />);
446 + });
447 +
448 + const activityElement = store.getElementAtIndex(1);
449 + expect(activityElement.displayName).toBe('Activity');
450 + expect(activityElement.isActivityHidden).toBe(true);
451 + expect(activityElement.isCollapsed).toBe(true);
452 +
453 + // Expand to access children
454 + store.toggleIsCollapsed(activityElement.id, false);
455 +
456 + const childElement = store.getElementAtIndex(2);
457 + expect(childElement.displayName).toBe('Child');
458 + expect(childElement.isInsideHiddenActivity).toBe(true);
459 +
460 + const grandChildElement = store.getElementAtIndex(3);
461 + expect(grandChildElement.displayName).toBe('GrandChild');
462 + expect(grandChildElement.isInsideHiddenActivity).toBe(true);
463 + });
464 +
465 + // @reactVersion >= 19
466 + it('should collapse hidden Activity subtree by default', async () => {
467 + const Activity = React.Activity || React.unstable_Activity;
468 +
469 + function Child() {
470 + return <div>child</div>;
471 + }
472 +
473 + function App({hidden}) {
474 + return (
475 + <Activity mode={hidden ? 'hidden' : 'visible'}>
476 + <Child />
477 + </Activity>
478 + );
479 + }
480 +
481 + // Hidden Activity should be collapsed
482 + await actAsync(() => {
483 + render(<App hidden={true} />);
484 + });
485 +
486 + expect(store).toMatchInlineSnapshot(`
487 + [root]
488 + ▾ <App>
489 + ▸ <Activity mode="hidden">
490 + `);
491 +
492 + // Toggle to visible — should expand
493 + await actAsync(() => {
494 + render(<App hidden={false} />);
495 + });
496 +
497 + expect(store).toMatchInlineSnapshot(`
498 + [root]
499 + ▾ <App>
500 + ▾ <Activity mode="visible">
501 + <Child>
502 + `);
503 +
504 + // Toggle back to hidden — should collapse again
505 + await actAsync(() => {
506 + render(<App hidden={true} />);
507 + });
508 +
509 + expect(store).toMatchInlineSnapshot(`
510 + [root]
511 + ▾ <App>
512 + ▸ <Activity mode="hidden">
513 + `);
514 + });
515 +
516 + // @reactVersion >= 19
517 + it('should dim nested visible Activity inside a hidden Activity', async () => {
518 + const Activity = React.Activity || React.unstable_Activity;
519 +
520 + function Leaf() {
521 + return <div>leaf</div>;
522 + }
523 +
524 + function App() {
525 + return (
526 + <Activity mode="hidden" name="outer">
527 + <Activity mode="visible" name="inner">
528 + <Leaf />
529 + </Activity>
530 + </Activity>
531 + );
532 + }
533 +
534 + await actAsync(() => {
535 + render(<App />);
536 + });
537 +
538 + // Outer Activity: hidden, collapsed, not dimmed itself
539 + const outerActivity = store.getElementAtIndex(1);
540 + expect(outerActivity.displayName).toBe('Activity');
541 + expect(outerActivity.nameProp).toBe('outer');
542 + expect(outerActivity.isActivityHidden).toBe(true);
543 + expect(outerActivity.isInsideHiddenActivity).toBe(false);
544 + expect(outerActivity.isCollapsed).toBe(true);
545 +
546 + // Expand to access inner elements
547 + store.toggleIsCollapsed(outerActivity.id, false);
548 +
549 + // Inner Activity: visible, but inside hidden outer so still dimmed
550 + const innerActivity = store.getElementAtIndex(2);
551 + expect(innerActivity.displayName).toBe('Activity');
552 + expect(innerActivity.nameProp).toBe('inner');
553 + expect(innerActivity.isActivityHidden).toBe(false);
554 + expect(innerActivity.isInsideHiddenActivity).toBe(true);
555 +
556 + // Leaf: inside both, dimmed
557 + const leaf = store.getElementAtIndex(3);
558 + expect(leaf.displayName).toBe('Leaf');
559 + expect(leaf.isInsideHiddenActivity).toBe(true);
560 + });
561 + });
562 +
563 describe('collapseNodesByDefault:false', () => {
564 beforeEach(() => {
565 store.collapseNodesByDefault = false;
@@ -3361,9 +3624,10 @@ describe('Store', () => {
3624 expect(store).toMatchInlineSnapshot(`
3625 [root]
3626 ▾ <App>
3364 - <Activity>
3627 + ▸ <Activity mode="hidden">
3628 <Suspense name="outer-suspense">
3629 [suspense-root] rects={[{x:1,y:2,width:15,height:1}]}
3630 + <Suspense name="inside-activity" uniqueSuspenders={false} rects={[{x:1,y:2,width:15,height:1}]}>
3631 <Suspense name="outer-suspense" uniqueSuspenders={true} rects={null}>
3632 `);
3633
@@ -3378,7 +3642,7 @@ describe('Store', () => {
3642 expect(store).toMatchInlineSnapshot(`
3643 [root]
3644 ▾ <App>
3381 - ▾ <Activity>
3645 + ▾ <Activity mode="visible">
3646 ▾ <Suspense name="inside-activity">
3647 <Component key="inside-activity">
3648 ▾ <Suspense name="outer-suspense">
@@ -3397,9 +3661,10 @@ describe('Store', () => {
3661 expect(store).toMatchInlineSnapshot(`
3662 [root]
3663 ▾ <App>
3400 - <Activity>
3664 + ▸ <Activity mode="hidden">
3665 <Suspense name="outer-suspense">
3666 [suspense-root] rects={[{x:1,y:2,width:15,height:1}, {x:1,y:2,width:15,height:1}]}
3667 + <Suspense name="inside-activity" uniqueSuspenders={false} rects={[{x:1,y:2,width:15,height:1}]}>
3668 <Suspense name="outer-suspense" uniqueSuspenders={true} rects={[{x:1,y:2,width:15,height:1}]}>
3669 <Suspense name="inner-suspense" uniqueSuspenders={false} rects={[{x:1,y:2,width:15,height:1}]}>
3670 `);
@@ -3411,7 +3676,7 @@ describe('Store', () => {
3676 expect(store).toMatchInlineSnapshot(`
3677 [root]
3678 ▾ <App>
3414 - ▾ <Activity>
3679 + ▾ <Activity mode="visible">
3680 ▾ <Suspense name="inside-activity">
3681 <Component key="inside-activity">
3682 ▾ <Suspense name="outer-suspense">
@@ -3604,7 +3869,7 @@ describe('Store', () => {
3869
3870 expect(store).toMatchInlineSnapshot(`
3871 [root]
3607 - <Activity>
3872 + ▸ <Activity mode="hidden">
3873 `);
3874
3875 await actAsync(() => {
@@ -3613,7 +3878,7 @@ describe('Store', () => {
3878
3879 expect(store).toMatchInlineSnapshot(`
3880 [root]
3616 - ▾ <Activity>
3881 + ▾ <Activity mode="visible">
3882 ▾ <Component key="left">
3883 <div>
3884 `);
packages/react-devtools-shared/src/__tests__/storeComponentFilters-test.js
+11 -10
@@ -229,9 +229,9 @@ describe('Store component filters', () => {
229
230 expect(store).toMatchInlineSnapshot(`
231 [root]
232 - ▾ <Activity>
232 + ▾ <Activity mode="visible">
233 <div>
234 - <Activity>
234 + ▸ <Activity mode="hidden">
235 `);
236
237 await actAsync(
@@ -244,6 +244,7 @@ describe('Store component filters', () => {
244 expect(store).toMatchInlineSnapshot(`
245 [root]
246 <div>
247 + <div>
248 `);
249
250 await actAsync(
@@ -255,9 +256,9 @@ describe('Store component filters', () => {
256
257 expect(store).toMatchInlineSnapshot(`
258 [root]
258 - ▾ <Activity>
259 + ▾ <Activity mode="visible">
260 <div>
260 - <Activity>
261 + ▸ <Activity mode="hidden">
262 `);
263 }
264 });
@@ -871,12 +872,12 @@ describe('Store component filters', () => {
872 expect(store).toMatchInlineSnapshot(`
873 [root]
874 ▾ <Root>
874 - ▾ <Activity name="/">
875 + ▾ <Activity name="/" mode="visible">
876 ▾ <Suspense>
877 <h1>
878 ▾ <main>
879 ▾ <Layout>
879 - ▾ <Activity name="/blog">
880 + ▾ <Activity name="/blog" mode="visible">
881 <h2>
882 ▾ <section>
883 ▾ <Page>
@@ -896,12 +897,12 @@ describe('Store component filters', () => {
897
898 expect(store).toMatchInlineSnapshot(`
899 [root]
899 - ▾ <Activity name="/">
900 + ▾ <Activity name="/" mode="visible">
901 ▾ <Suspense>
902 <h1>
903 ▾ <main>
904 ▾ <Layout>
904 - ▸ <Activity name="/blog">
905 + ▸ <Activity name="/blog" mode="visible">
906 [suspense-root] rects={[{x:1,y:2,width:4,height:1}, {x:1,y:2,width:13,height:1}]}
907 <Suspense name="Unknown" uniqueSuspenders={false} rects={[{x:1,y:2,width:4,height:1}, {x:1,y:2,width:13,height:1}]}>
908 <Suspense name="Page" uniqueSuspenders={true} rects={[{x:1,y:2,width:9,height:1}]}>
@@ -912,12 +913,12 @@ describe('Store component filters', () => {
913 expect(store).toMatchInlineSnapshot(`
914 [root]
915 ▾ <Root>
915 - ▾ <Activity name="/">
916 + ▾ <Activity name="/" mode="visible">
917 ▾ <Suspense>
918 <h1>
919 ▾ <main>
920 ▾ <Layout>
920 - ▾ <Activity name="/blog">
921 + ▾ <Activity name="/blog" mode="visible">
922 <h2>
923 ▾ <section>
924 ▾ <Page>
packages/react-devtools-shared/src/backend/fiber/renderer.js
+91 -11
@@ -43,6 +43,8 @@ import {
43 ElementTypeActivity,
44 ElementTypeVirtual,
45 StrictMode,
46 + ActivityHiddenMode,
47 + ActivityVisibleMode,
48 } from 'react-devtools-shared/src/frontend/types';
49 import {
50 deletePathInObject,
@@ -1918,6 +1920,20 @@ export function attach(
1920 pushOperation(StrictMode);
1921 }
1922 }
1923 +
1924 + // If this is an Activity component, check if it's hidden.
1925 + if (fiber.tag === ActivityComponent) {
1926 + const offscreenChild = fiber.child;
1927 + if (
1928 + offscreenChild !== null &&
1929 + offscreenChild.tag === OffscreenComponent &&
1930 + offscreenChild.memoizedState !== null
1931 + ) {
1932 + pushOperation(TREE_OPERATION_SET_SUBTREE_MODE);
1933 + pushOperation(id);
1934 + pushOperation(ActivityHiddenMode);
1935 + }
1936 + }
1937 }
1938
1939 let componentLogsEntry = fiberToComponentLogsMap.get(fiber);
@@ -2582,6 +2598,17 @@ export function attach(
2598 }
2599 }
2600
2601 + // Returns true if this is a hidden OffscreenComponent that belongs to
2602 + // an Activity boundary (as opposed to Suspense). Activity's children
2603 + // should remain visible in the DevTools tree even when hidden.
2604 + function isActivityHiddenOffscreen(fiber: Fiber): boolean {
2605 + return (
2606 + isHiddenOffscreen(fiber) &&
2607 + fiber.return !== null &&
2608 + fiber.return.tag === ActivityComponent
2609 + );
2610 + }
2611 +
2612 /**
2613 * Offscreen of suspended Suspense
2614 */
@@ -3486,7 +3513,16 @@ export function attach(
3513 isInDisconnectedSubtree = stashedDisconnected;
3514 }
3515 } else if (isHiddenOffscreen(fiber)) {
3489 - // hidden Activity is noisy.
3516 + if (isActivityHiddenOffscreen(fiber)) {
3517 + // Activity's hidden children should still be visible in DevTools.
3518 + if (fiber.child !== null) {
3519 + mountChildrenRecursively(
3520 + fiber.child,
3521 + traceNearestHostComponentUpdate,
3522 + );
3523 + }
3524 + }
3525 + // Otherwise, hidden Offscreen (e.g. non-Activity) is noisy.
3526 // Including it may show overlapping Suspense rects
3527 } else if (fiber.tag === SuspenseComponent && OffscreenComponent === -1) {
3528 // Legacy Suspense without the Offscreen wrapper. For the modern Suspense we just handle the
@@ -3819,8 +3855,9 @@ export function attach(
3855 while (child !== null) {
3856 if (child.kind === FILTERED_FIBER_INSTANCE) {
3857 const fiber = child.data;
3822 - if (isHiddenOffscreen(fiber)) {
3858 + if (isHiddenOffscreen(fiber) && !isActivityHiddenOffscreen(fiber)) {
3859 // The children of this Offscreen are hidden so they don't get added.
3860 + // Activity's hidden children are still shown in the tree.
3861 } else {
3862 addUnfilteredChildrenIDs(child, nextChildren);
3863 }
@@ -4604,22 +4641,42 @@ export function attach(
4641 updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren;
4642 }
4643 } else if (nextIsHidden) {
4607 - if (prevWasHidden) {
4644 + if (isActivityHiddenOffscreen(nextFiber)) {
4645 + // Activity's hidden children stay visible in the DevTools tree.
4646 + // Whether staying hidden or transitioning to hidden, update normally.
4647 + updateFlags |= updateChildrenRecursively(
4648 + nextFiber.child,
4649 + prevFiber.child,
4650 + traceNearestHostComponentUpdate,
4651 + );
4652 + } else if (prevWasHidden) {
4653 // still hidden. Nothing to do.
4654 } else {
4655 // We're hiding the children. Remove them from the Frontend
4656 unmountRemainingChildren();
4657 }
4658 } else if (prevWasHidden && !nextIsHidden) {
4614 - // Since we don't mount hidden children and unmount children when hiding,
4615 - // we need to enter the mount path when revealing.
4616 - const nextChildSet = nextFiber.child;
4617 - if (nextChildSet !== null) {
4618 - mountChildrenRecursively(
4619 - nextChildSet,
4659 + if (
4660 + nextFiber.return !== null &&
4661 + nextFiber.return.tag === ActivityComponent
4662 + ) {
4663 + // Activity children were never unmounted, so just update normally.
4664 + updateFlags |= updateChildrenRecursively(
4665 + nextFiber.child,
4666 + prevFiber.child,
4667 traceNearestHostComponentUpdate,
4668 );
4622 - updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren;
4669 + } else {
4670 + // Since we don't mount hidden children and unmount children when hiding,
4671 + // we need to enter the mount path when revealing.
4672 + const nextChildSet = nextFiber.child;
4673 + if (nextChildSet !== null) {
4674 + mountChildrenRecursively(
4675 + nextChildSet,
4676 + traceNearestHostComponentUpdate,
4677 + );
4678 + updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren;
4679 + }
4680 }
4681 } else if (
4682 nextFiber.tag === SuspenseComponent &&
@@ -4753,6 +4810,27 @@ export function attach(
4810 }
4811
4812 if (fiberInstance !== null) {
4813 + // Detect Activity hidden/visible mode changes.
4814 + if (
4815 + prevFiber.tag === ActivityComponent &&
4816 + nextFiber.tag === ActivityComponent &&
4817 + fiberInstance.kind === FIBER_INSTANCE
4818 + ) {
4819 + const prevOffscreen = prevFiber.child;
4820 + const nextOffscreen = nextFiber.child;
4821 + if (prevOffscreen !== null && nextOffscreen !== null) {
4822 + const prevHidden = isHiddenOffscreen(prevOffscreen);
4823 + const nextHidden = isHiddenOffscreen(nextOffscreen);
4824 + if (prevHidden !== nextHidden) {
4825 + pushOperation(TREE_OPERATION_SET_SUBTREE_MODE);
4826 + pushOperation(fiberInstance.id);
4827 + pushOperation(
4828 + nextHidden ? ActivityHiddenMode : ActivityVisibleMode,
4829 + );
4830 + }
4831 + }
4832 + }
4833 +
4834 removePreviousSuspendedBy(
4835 fiberInstance,
4836 previousSuspendedBy,
@@ -4895,9 +4973,11 @@ export function attach(
4973 if (
4974 (child.kind === FIBER_INSTANCE ||
4975 child.kind === FILTERED_FIBER_INSTANCE) &&
4898 - isHiddenOffscreen(child.data)
4976 + isHiddenOffscreen(child.data) &&
4977 + !isActivityHiddenOffscreen(child.data)
4978 ) {
4979 // This instance's children should remain disconnected.
4980 + // Activity's hidden children are still shown in the tree.
4981 } else {
4982 reconnectChildrenRecursively(child);
4983 }
packages/react-devtools-shared/src/devtools/store.js
+50 -2
@@ -48,7 +48,11 @@ import {
48 BRIDGE_PROTOCOL,
49 currentBridgeProtocol,
50 } from 'react-devtools-shared/src/bridge';
51 -import {StrictMode} from 'react-devtools-shared/src/frontend/types';
51 +import {
52 + StrictMode,
53 + ActivityHiddenMode,
54 + ActivityVisibleMode,
55 +} from 'react-devtools-shared/src/frontend/types';
56 import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
57
58 import type {
@@ -1491,6 +1495,8 @@ export default class Store extends EventEmitter<{
1495 id,
1496 isCollapsed: false, // Never collapse roots; it would hide the entire tree.
1497 isStrictModeNonCompliant,
1498 + isActivityHidden: false,
1499 + isInsideHiddenActivity: false,
1500 key: null,
1501 nameProp: null,
1502 ownerID: 0,
@@ -1560,6 +1566,10 @@ export default class Store extends EventEmitter<{
1566 id,
1567 isCollapsed: this._collapseNodesByDefault,
1568 isStrictModeNonCompliant: parentElement.isStrictModeNonCompliant,
1569 + isActivityHidden: false,
1570 + isInsideHiddenActivity:
1571 + parentElement.isInsideHiddenActivity ||
1572 + parentElement.isActivityHidden,
1573 key,
1574 nameProp,
1575 ownerID,
@@ -1728,6 +1738,42 @@ export default class Store extends EventEmitter<{
1738 this._recursivelyUpdateSubtree(id, element => {
1739 element.isStrictModeNonCompliant = false;
1740 });
1741 + } else if (mode === ActivityHiddenMode) {
1742 + const element = this._idToElement.get(id);
1743 + if (element != null) {
1744 + element.isActivityHidden = true;
1745 + element.children.forEach(childID =>
1746 + this._recursivelyUpdateSubtree(childID, child => {
1747 + child.isInsideHiddenActivity = true;
1748 + }),
1749 + );
1750 + // Collapse hidden Activity subtrees by default.
1751 + if (!element.isCollapsed) {
1752 + element.isCollapsed = true;
1753 + if (element.children.length > 0) {
1754 + const weightDelta = 1 - element.weight;
1755 + const parentElement = this._idToElement.get(element.parentID);
1756 + this._adjustParentTreeWeight(parentElement, weightDelta);
1757 + }
1758 + }
1759 + }
1760 + } else if (mode === ActivityVisibleMode) {
1761 + const element = this._idToElement.get(id);
1762 + if (element != null) {
1763 + element.isActivityHidden = false;
1764 + element.children.forEach(childID =>
1765 + this._recursivelyUpdateSubtree(childID, child => {
1766 + child.isInsideHiddenActivity = false;
1767 + }),
1768 + );
1769 + // Expand Activity subtree when it becomes visible.
1770 + if (element.isCollapsed && element.children.length > 0) {
1771 + element.isCollapsed = false;
1772 + const weightDelta = element.weight - 1;
1773 + const parentElement = this._idToElement.get(element.parentID);
1774 + this._adjustParentTreeWeight(parentElement, weightDelta);
1775 + }
1776 + }
1777 }
1778
1779 if (__DEBUG__) {
@@ -2075,7 +2121,9 @@ export default class Store extends EventEmitter<{
2121 const previousHasUniqueSuspenders = suspense.hasUniqueSuspenders;
2122 debug(
2123 'Suspender changes',
2078 - `Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} (was ${String(previousHasUniqueSuspenders)})`,
2124 + `Suspense node ${id} unique suspenders set to ${String(
2125 + hasUniqueSuspenders,
2126 + )} (was ${String(previousHasUniqueSuspenders)})`,
2127 );
2128 }
2129
packages/react-devtools-shared/src/devtools/utils.js
+7 -1
@@ -10,6 +10,7 @@
10 import JSON5 from 'json5';
11
12 import type {ReactFunctionLocation} from 'shared/ReactTypes';
13 +import {ElementTypeActivity} from 'react-devtools-shared/src/frontend/types';
14 import type {
15 Element,
16 SuspenseNode,
@@ -44,6 +45,11 @@ export function printElement(
45 const hocs =
46 hocDisplayNames === null ? '' : ` [${hocDisplayNames.join('][')}]`;
47
48 + let mode = '';
49 + if (element.type === ElementTypeActivity) {
50 + mode = ` mode="${element.isActivityHidden ? 'hidden' : 'visible'}"`;
51 + }
52 +
53 let suffix = '';
54 if (includeWeight) {
55 suffix = ` (${element.isCollapsed ? 1 : element.weight})`;
@@ -51,7 +57,7 @@ export function printElement(
57
58 return `${' '.repeat(element.depth + 1)}${prefix} <${
59 element.displayName || 'null'
54 - }${key}${name}>${hocs}${suffix}`;
60 + }${key}${name}${mode}>${hocs}${suffix}`;
61 }
62
63 function printRects(rects: SuspenseNode['rects']): string {
packages/react-devtools-shared/src/devtools/views/Components/Element.js
+20
@@ -126,6 +126,8 @@ export default function Element({data, index, style}: Props): React.Node {
126 displayName,
127 hocDisplayNames,
128 isStrictModeNonCompliant,
129 + isActivityHidden,
130 + isInsideHiddenActivity,
131 key,
132 nameProp,
133 compiledWithForget,
@@ -168,9 +170,15 @@ export default function Element({data, index, style}: Props): React.Node {
170 onMouseLeave={handleMouseLeave}
171 onMouseDown={handleClick}
172 onDoubleClick={handleDoubleClick}
173 + title={
174 + isInsideHiddenActivity
175 + ? 'This component is inside a hidden Activity subtree.'
176 + : undefined
177 + }
178 style={{
179 ...style,
180 paddingLeft: elementOffset,
181 + opacity: isInsideHiddenActivity ? 0.75 : 1,
182 }}
183 data-testname="ComponentTreeListItem">
184 {/* This wrapper is used by Tree for measurement purposes. */}
@@ -207,6 +215,18 @@ export default function Element({data, index, style}: Props): React.Node {
215 </Fragment>
216 )}
217
218 + {element.type === ElementTypeActivity && (
219 + <Fragment>
220 + &nbsp;<span className={styles.KeyName}>mode</span>="
221 + <span
222 + className={styles.KeyValue}
223 + title={isActivityHidden ? 'hidden' : 'visible'}>
224 + {isActivityHidden ? 'hidden' : 'visible'}
225 + </span>
226 + "
227 + </Fragment>
228 + )}
229 +
230 <IndexableElementBadges
231 hocDisplayNames={hocDisplayNames}
232 compiledWithForget={compiledWithForget}
packages/react-devtools-shared/src/frontend/types.js
+8
@@ -156,6 +156,8 @@ export type Plugins = {
156 };
157
158 export const StrictMode = 1;
159 +export const ActivityHiddenMode = 2;
160 +export const ActivityVisibleMode = 3;
161
162 // Each element on the frontend corresponds to an ElementID (e.g. a Fiber) on the backend.
163 // Some of its information (e.g. id, type, displayName) come from the backend.
@@ -191,6 +193,12 @@ export type Element = {
193 // Only true for React versions supporting StrictMode.
194 isStrictModeNonCompliant: boolean,
195
196 + // Whether this Activity element has mode="hidden".
197 + isActivityHidden: boolean,
198 +
199 + // Whether this element is inside a hidden Activity subtree.
200 + isInsideHiddenActivity: boolean,
201 +
202 // If component is compiled with Forget, the backend will send its name as Forget(...)
203 // Later, on the frontend side, we will strip HOC names and Forget prefix.
204 compiledWithForget: boolean,
packages/react-devtools-shell/src/app/ActivityTree/index.js new
+39
@@ -0,0 +1,39 @@
1 +import * as React from 'react';
2 +import {useState} from 'react';
3 +
4 +const Activity = React.Activity || React.unstable_Activity;
5 +
6 +function Profile({name}) {
7 + return (
8 + <div>
9 + <h4>{name}</h4>
10 + <Bio />
11 + </div>
12 + );
13 +}
14 +
15 +function Bio() {
16 + return <p>This is a bio section.</p>;
17 +}
18 +
19 +export default function ActivityTree() {
20 + const [mode, setMode] = useState('hidden');
21 +
22 + if (Activity == null) {
23 + return null;
24 + }
25 +
26 + return (
27 + <>
28 + <h2>Activity</h2>
29 + <button
30 + onClick={() => setMode(m => (m === 'visible' ? 'hidden' : 'visible'))}>
31 + Toggle mode (current: {mode})
32 + </button>
33 + <Activity mode={mode} name="profile-panel">
34 + <Profile name="Alice" />
35 + <Profile name="Bob" />
36 + </Activity>
37 + </>
38 + );
39 +}
packages/react-devtools-shell/src/app/index.js
+2
@@ -20,6 +20,7 @@ import ErrorBoundaries from './ErrorBoundaries';
20 import PartiallyStrictApp from './PartiallyStrictApp';
21 import Segments from './Segments';
22 import SuspenseTree from './SuspenseTree';
23 +import ActivityTree from './ActivityTree';
24 import TraceUpdatesTest from './TraceUpdatesTest';
25 import {ignoreErrors, ignoreLogs, ignoreWarnings} from './console';
26
@@ -114,6 +115,7 @@ function mountTestApp() {
115 mountApp(SuspenseTree);
116 mountApp(DeeplyNestedComponents);
117 mountApp(Iframe);
118 + mountApp(ActivityTree);
119 mountApp(TraceUpdatesTest);
120 mountApp(Segments);
121