[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
+ <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