[DevTools] Apply Activity slice filter when double clicking Activity (#34908)
Sebastian "Sebbie" Silbermann committed
Nov 8, 2025 at 18:09 UTC
5a9921b839ad8e3cf0069f23c75045fa94373643
22 files changed
+958
-101
packages/react-devtools-shared/src/__tests__/store-test.js
-2
@@ -3283,8 +3283,6 @@ describe('Store', () => {
3283
<Suspense name="Outer" rects={null}>
3284
`);
3285
3286
- console.log('...........................');
3287
-
3286
await actAsync(() => {
3287
resolve('loaded');
3288
});
packages/react-devtools-shared/src/__tests__/storeComponentFilters-test.js
+123
-13
@@ -24,6 +24,16 @@ describe('Store component filters', () => {
24
let utils;
25
let actAsync;
26
27
+ beforeAll(() => {
28
+ // JSDDOM doesn't implement getClientRects so we're just faking one for testing purposes
29
+ Element.prototype.getClientRects = function (this: Element) {
30
+ const textContent = this.textContent;
31
+ return [
32
+ new DOMRect(1, 2, textContent.length, textContent.split('\n').length),
33
+ ];
34
+ };
35
+ });
36
+
37
beforeEach(() => {
38
agent = global.agent;
39
bridge = global.bridge;
@@ -158,9 +168,9 @@ describe('Store component filters', () => {
168
<div>
169
▾ <Suspense>
170
<div>
161
- [suspense-root] rects={[]}
162
- <Suspense name="Unknown" rects={[]}>
163
- <Suspense name="Unknown" rects={[]}>
171
+ [suspense-root] rects={[{x:1,y:2,width:7,height:1}, {x:1,y:2,width:6,height:1}]}
172
+ <Suspense name="Unknown" rects={[{x:1,y:2,width:7,height:1}]}>
173
+ <Suspense name="Unknown" rects={[{x:1,y:2,width:6,height:1}]}>
174
`);
175
176
await actAsync(
@@ -176,9 +186,9 @@ describe('Store component filters', () => {
186
<div>
187
▾ <Suspense>
188
<div>
179
- [suspense-root] rects={[]}
180
- <Suspense name="Unknown" rects={[]}>
181
- <Suspense name="Unknown" rects={[]}>
189
+ [suspense-root] rects={[{x:1,y:2,width:7,height:1}, {x:1,y:2,width:6,height:1}]}
190
+ <Suspense name="Unknown" rects={[{x:1,y:2,width:7,height:1}]}>
191
+ <Suspense name="Unknown" rects={[{x:1,y:2,width:6,height:1}]}>
192
`);
193
194
await actAsync(
@@ -194,9 +204,9 @@ describe('Store component filters', () => {
204
<div>
205
▾ <Suspense>
206
<div>
197
- [suspense-root] rects={[]}
198
- <Suspense name="Unknown" rects={[]}>
199
- <Suspense name="Unknown" rects={[]}>
207
+ [suspense-root] rects={[{x:1,y:2,width:7,height:1}, {x:1,y:2,width:6,height:1}]}
208
+ <Suspense name="Unknown" rects={[{x:1,y:2,width:7,height:1}]}>
209
+ <Suspense name="Unknown" rects={[{x:1,y:2,width:6,height:1}]}>
210
`);
211
});
212
@@ -798,8 +808,8 @@ describe('Store component filters', () => {
808
<div key="loading">
809
▾ <ErrorBoundary>
810
<div key="did-error">
801
- [suspense-root] rects={[]}
802
- <Suspense name="App" rects={[]}>
811
+ [suspense-root] rects={[{x:1,y:2,width:0,height:1}, {x:1,y:2,width:0,height:1}, {x:1,y:2,width:0,height:1}]}
812
+ <Suspense name="App" rects={[{x:1,y:2,width:0,height:1}]}>
813
`);
814
815
await actAsync(() => {
@@ -814,8 +824,108 @@ describe('Store component filters', () => {
824
<div key="suspense-content">
825
▾ <ErrorBoundary>
826
<div key="error-content">
817
- [suspense-root] rects={[]}
818
- <Suspense name="Unknown" rects={[]}>
827
+ [suspense-root] rects={[{x:1,y:2,width:0,height:1}, {x:1,y:2,width:0,height:1}]}
828
+ <Suspense name="Unknown" rects={[{x:1,y:2,width:0,height:1}]}>
829
+ `);
830
+ });
831
+
832
+ // @reactVersion >= 19.2
833
+ it('can filter by Activity slices', async () => {
834
+ const Activity = React.Activity;
835
+ const immediate = Promise.resolve(<div>Immediate</div>);
836
+
837
+ function Root({children}) {
838
+ return (
839
+ <Activity name="/" mode="visible">
840
+ <React.Suspense fallback="Loading...">
841
+ <h1>Root</h1>
842
+ <main>{children}</main>
843
+ </React.Suspense>
844
+ </Activity>
845
+ );
846
+ }
847
+
848
+ function Layout({children}) {
849
+ return (
850
+ <Activity name="/blog" mode="visible">
851
+ <h2>Blog</h2>
852
+ <section>{children}</section>
853
+ </Activity>
854
+ );
855
+ }
856
+
857
+ function Page() {
858
+ return <React.Suspense fallback="Loading...">{immediate}</React.Suspense>;
859
+ }
860
+
861
+ await actAsync(async () =>
862
+ render(
863
+ <Root>
864
+ <Layout>
865
+ <Page />
866
+ </Layout>
867
+ </Root>,
868
+ ),
869
+ );
870
+
871
+ expect(store).toMatchInlineSnapshot(`
872
+ [root]
873
+ ▾ <Root>
874
+ ▾ <Activity name="/">
875
+ ▾ <Suspense>
876
+ <h1>
877
+ ▾ <main>
878
+ ▾ <Layout>
879
+ ▾ <Activity name="/blog">
880
+ <h2>
881
+ ▾ <section>
882
+ ▾ <Page>
883
+ ▾ <Suspense>
884
+ <div>
885
+ [suspense-root] rects={[{x:1,y:2,width:4,height:1}, {x:1,y:2,width:13,height:1}]}
886
+ <Suspense name="Root" rects={[{x:1,y:2,width:4,height:1}, {x:1,y:2,width:13,height:1}]}>
887
+ <Suspense name="Page" rects={[{x:1,y:2,width:9,height:1}]}>
888
+ `);
889
+
890
+ await actAsync(
891
+ async () =>
892
+ (store.componentFilters = [
893
+ utils.createActivitySliceFilter(store.getElementIDAtIndex(1)),
894
+ ]),
895
+ );
896
+
897
+ expect(store).toMatchInlineSnapshot(`
898
+ [root]
899
+ ▾ <Activity name="/">
900
+ ▾ <Suspense>
901
+ <h1>
902
+ ▾ <main>
903
+ ▾ <Layout>
904
+ ▸ <Activity name="/blog">
905
+ [suspense-root] rects={[{x:1,y:2,width:4,height:1}, {x:1,y:2,width:13,height:1}]}
906
+ <Suspense name="Unknown" rects={[{x:1,y:2,width:4,height:1}, {x:1,y:2,width:13,height:1}]}>
907
+ <Suspense name="Page" rects={[{x:1,y:2,width:9,height:1}]}>
908
+ `);
909
+
910
+ await actAsync(async () => (store.componentFilters = []));
911
+
912
+ expect(store).toMatchInlineSnapshot(`
913
+ [root]
914
+ ▾ <Root>
915
+ ▾ <Activity name="/">
916
+ ▾ <Suspense>
917
+ <h1>
918
+ ▾ <main>
919
+ ▾ <Layout>
920
+ ▾ <Activity name="/blog">
921
+ <h2>
922
+ ▾ <section>
923
+ ▾ <Page>
924
+ ▾ <Suspense>
925
+ <div>
926
+ [suspense-root] rects={[{x:1,y:2,width:4,height:1}, {x:1,y:2,width:13,height:1}]}
927
+ <Suspense name="Root" rects={[{x:1,y:2,width:4,height:1}, {x:1,y:2,width:13,height:1}]}>
928
+ <Suspense name="Page" rects={[{x:1,y:2,width:9,height:1}]}>
929
`);
930
});
931
});
packages/react-devtools-shared/src/__tests__/utils.js
+13
@@ -328,6 +328,19 @@ export function createLocationFilter(
328
};
329
}
330
331
+export function createActivitySliceFilter(
332
+ activityID: Element['id'],
333
+ isEnabled: boolean = true,
334
+) {
335
+ const Types = require('react-devtools-shared/src/frontend/types');
336
+ return {
337
+ type: Types.ComponentFilterActivitySlice,
338
+ isEnabled,
339
+ isValid: true,
340
+ activityID: activityID,
341
+ };
342
+}
343
+
344
export function getRendererID(): number {
345
if (global.agent == null) {
346
throw Error('Agent unavailable.');
packages/react-devtools-shared/src/backend/fiber/renderer.js
+134
-9
@@ -26,6 +26,7 @@ import {
26
ComponentFilterHOC,
27
ComponentFilterLocation,
28
ComponentFilterEnvironmentName,
29
+ ComponentFilterActivitySlice,
30
ElementTypeClass,
31
ElementTypeContext,
32
ElementTypeFunction,
@@ -53,7 +54,7 @@ import {
54
renamePathInObject,
55
setInObject,
56
utfEncodeString,
56
- filterOutLocationComponentFilters,
57
+ persistableComponentFilters,
58
} from 'react-devtools-shared/src/utils';
59
import {
60
formatConsoleArgumentsToSingleString,
@@ -85,6 +86,7 @@ import {
86
TREE_OPERATION_SET_SUBTREE_MODE,
87
TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS,
88
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
89
+ TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE,
90
SUSPENSE_TREE_OPERATION_ADD,
91
SUSPENSE_TREE_OPERATION_REMOVE,
92
SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
@@ -170,6 +172,7 @@ import type {
172
} from '../types';
173
import type {
174
ComponentFilter,
175
+ ActivitySliceFilter,
176
ElementType,
177
Plugins,
178
} from 'react-devtools-shared/src/frontend/types';
@@ -868,6 +871,9 @@ const idToDevToolsInstanceMap: Map<
871
FiberInstance | VirtualInstance,
872
> = new Map();
873
874
+let focusedActivityID: null | FiberInstance['id'] = null;
875
+let focusedActivity: null | Fiber = null;
876
+
877
const idToSuspenseNodeMap: Map<FiberInstance['id'], SuspenseNode> = new Map();
878
879
// Map of canonical HostInstances to the nearest parent DevToolsInstance.
@@ -1435,16 +1441,25 @@ export function attach(
1441
const hideElementsWithPaths: Set<RegExp> = new Set();
1442
const hideElementsWithTypes: Set<ElementType> = new Set();
1443
const hideElementsWithEnvs: Set<string> = new Set();
1444
+ let isInFocusedActivity: boolean = true;
1445
1446
// Highlight updates
1447
let traceUpdatesEnabled: boolean = false;
1448
const traceUpdatesForNodes: Set<HostInstance> = new Set();
1449
1443
- function applyComponentFilters(componentFilters: Array<ComponentFilter>) {
1450
+ function applyComponentFilters(
1451
+ componentFilters: Array<ComponentFilter>,
1452
+ nextActivitySlice: null | Fiber,
1453
+ ) {
1454
hideElementsWithTypes.clear();
1455
hideElementsWithDisplayNames.clear();
1456
hideElementsWithPaths.clear();
1457
hideElementsWithEnvs.clear();
1458
+ const previousFocusedActivityID = focusedActivityID;
1459
+ focusedActivityID = null;
1460
+ focusedActivity = null;
1461
+ // Consider everything in the slice by default
1462
+ isInFocusedActivity = true;
1463
1464
componentFilters.forEach(componentFilter => {
1465
if (!componentFilter.isEnabled) {
@@ -1473,6 +1488,25 @@ export function attach(
1488
case ComponentFilterEnvironmentName:
1489
hideElementsWithEnvs.add(componentFilter.value);
1490
break;
1491
+ case ComponentFilterActivitySlice:
1492
+ if (
1493
+ nextActivitySlice !== null &&
1494
+ nextActivitySlice.tag === ActivityComponent
1495
+ ) {
1496
+ focusedActivity = nextActivitySlice;
1497
+ isInFocusedActivity = false;
1498
+ if (componentFilter.rendererID !== rendererID) {
1499
+ // We filtered an Activity from another renderer.
1500
+ // We need to restore the instance ID since we won't be mounting it
1501
+ // in this renderer.
1502
+ focusedActivityID = previousFocusedActivityID;
1503
+ }
1504
+ } else {
1505
+ // We're not filtering by activity slice after all.
1506
+ // Don't mark the filter as disabled here.
1507
+ // Otherwise updateComponentFilters() will think no enabled filter was changed.
1508
+ }
1509
+ break;
1510
default:
1511
console.warn(
1512
`Invalid component filter type "${componentFilter.type}"`,
@@ -1486,11 +1520,9 @@ export function attach(
1520
// because they are stored in localStorage within the context of the extension.
1521
// Instead it relies on the extension to pass filters through.
1522
if (window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ != null) {
1489
- const componentFiltersWithoutLocationBasedOnes =
1490
- filterOutLocationComponentFilters(
1491
- window.__REACT_DEVTOOLS_COMPONENT_FILTERS__,
1492
- );
1493
- applyComponentFilters(componentFiltersWithoutLocationBasedOnes);
1523
+ const restoredComponentFilters: Array<ComponentFilter> =
1524
+ persistableComponentFilters(window.__REACT_DEVTOOLS_COMPONENT_FILTERS__);
1525
+ applyComponentFilters(restoredComponentFilters, null);
1526
} else {
1527
// Unfortunately this feature is not expected to work for React Native for now.
1528
// It would be annoying for us to spam YellowBox warnings with unactionable stuff,
@@ -1498,7 +1530,7 @@ export function attach(
1530
//console.warn('⚛ DevTools: Could not locate saved component filters');
1531
1532
// Fallback to assuming the default filters in this case.
1501
- applyComponentFilters(getDefaultComponentFilters());
1533
+ applyComponentFilters(getDefaultComponentFilters(), null);
1534
}
1535
1536
// If necessary, we can revisit optimizing this operation.
@@ -1517,6 +1549,22 @@ export function attach(
1549
const previousForcedErrors =
1550
forceErrorForFibers.size > 0 ? new Map(forceErrorForFibers) : null;
1551
1552
+ // The ID will be based on the old tree. We need to find the Fiber based on
1553
+ // that ID before we unmount everything. We set the activity slice ID once
1554
+ // we mount it again.
1555
+ let nextFocusedActivity: null | Fiber = null;
1556
+ let focusedActivityFilter: null | ActivitySliceFilter = null;
1557
+ for (let i = 0; i < componentFilters.length; i++) {
1558
+ const filter = componentFilters[i];
1559
+ if (filter.type === ComponentFilterActivitySlice && filter.isEnabled) {
1560
+ focusedActivityFilter = filter;
1561
+ const instance = idToDevToolsInstanceMap.get(filter.activityID);
1562
+ if (instance !== undefined && instance.kind === FIBER_INSTANCE) {
1563
+ nextFocusedActivity = instance.data;
1564
+ }
1565
+ }
1566
+ }
1567
+
1568
// Recursively unmount all roots.
1569
hook.getFiberRoots(rendererID).forEach(root => {
1570
const rootInstance = rootToFiberInstanceMap.get(root);
@@ -1532,7 +1580,17 @@ export function attach(
1580
currentRoot = (null: any);
1581
});
1582
1535
- applyComponentFilters(componentFilters);
1583
+ if (
1584
+ nextFocusedActivity !== focusedActivity &&
1585
+ (focusedActivityFilter === null ||
1586
+ focusedActivityFilter.rendererID === rendererID)
1587
+ ) {
1588
+ // When we find the applied instance during mount we will send the actual ID.
1589
+ // Otherwise 0 will indicate that we unfocused the activity slice.
1590
+ pushOperation(TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE);
1591
+ pushOperation(0);
1592
+ }
1593
+ applyComponentFilters(componentFilters, nextFocusedActivity);
1594
1595
// Reset pseudo counters so that new path selections will be persisted.
1596
rootDisplayNameCounter.clear();
@@ -1592,6 +1650,13 @@ export function attach(
1650
currentRoot = (null: any);
1651
});
1652
1653
+ // We need to write back the new ID for the focused Fiber.
1654
+ // Otherwise subsequent filter applications will try to focus based on the old ID.
1655
+ // This is also relevant to filter across renderers.
1656
+ if (focusedActivityFilter !== null && focusedActivityID !== null) {
1657
+ focusedActivityFilter.activityID = focusedActivityID;
1658
+ }
1659
+
1660
flushPendingEvents();
1661
1662
needsToFlushComponentLogs = false;
@@ -1621,6 +1686,10 @@ export function attach(
1686
data: ReactComponentInfo,
1687
secondaryEnv: null | string,
1688
): boolean {
1689
+ if (!isInFocusedActivity) {
1690
+ return true;
1691
+ }
1692
+
1693
// For purposes of filtering Server Components are always Function Components.
1694
// Environment will be used to filter Server vs Client.
1695
// Technically they can be forwardRef and memo too but those filters will go away
@@ -1656,6 +1725,11 @@ export function attach(
1725
function shouldFilterFiber(fiber: Fiber): boolean {
1726
const {tag, type, key} = fiber;
1727
1728
+ // It is never valid to filter the root element.
1729
+ if (tag !== HostRoot && !isInFocusedActivity) {
1730
+ return true;
1731
+ }
1732
+
1733
switch (tag) {
1734
case DehydratedSuspenseComponent:
1735
// TODO: ideally we would show dehydrated Suspense immediately.
@@ -4020,11 +4094,23 @@ export function attach(
4094
fiber: Fiber,
4095
traceNearestHostComponentUpdate: boolean,
4096
): void {
4097
+ const isFocusedActivityEntry =
4098
+ focusedActivity !== null &&
4099
+ (fiber === focusedActivity || fiber.alternate === focusedActivity);
4100
+ if (isFocusedActivityEntry) {
4101
+ isInFocusedActivity = true;
4102
+ }
4103
+
4104
const shouldIncludeInTree = !shouldFilterFiber(fiber);
4105
let newInstance = null;
4106
let newSuspenseNode = null;
4107
if (shouldIncludeInTree) {
4108
newInstance = recordMount(fiber, reconcilingParent);
4109
+ if (isFocusedActivityEntry) {
4110
+ focusedActivityID = newInstance.id;
4111
+ pushOperation(TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE);
4112
+ pushOperation(newInstance.id);
4113
+ }
4114
if (fiber.tag === SuspenseComponent || fiber.tag === HostRoot) {
4115
newSuspenseNode = createSuspenseNode(newInstance);
4116
// Measure this Suspense node. In general we shouldn't do this until we have
@@ -4140,6 +4226,7 @@ export function attach(
4226
const stashedSuspenseParent = reconcilingParentSuspenseNode;
4227
const stashedSuspensePrevious = previouslyReconciledSiblingSuspenseNode;
4228
const stashedSuspenseRemaining = remainingReconcilingChildrenSuspenseNodes;
4229
+ const stashedIsInActivitySlice = isInFocusedActivity;
4230
if (newInstance !== null) {
4231
// Push a new DevTools instance parent while reconciling this subtree.
4232
reconcilingParent = newInstance;
@@ -4153,6 +4240,17 @@ export function attach(
4240
remainingReconcilingChildrenSuspenseNodes = null;
4241
shouldPopSuspenseNode = true;
4242
}
4243
+ if (
4244
+ !isFocusedActivityEntry &&
4245
+ focusedActivity !== null &&
4246
+ fiber.tag === ActivityComponent
4247
+ ) {
4248
+ // We're not filtering how Activity within the focused activity.
4249
+ // We cut of the bottom in the Frontend if we want to just show the
4250
+ // Activity slice instead of all Activity descendants.
4251
+ // The filtering in the backend only happens because filtering out
4252
+ // everything above the focused Activity is hard to implement in the frontend.
4253
+ }
4254
try {
4255
if (traceUpdatesEnabled) {
4256
if (traceNearestHostComponentUpdate) {
@@ -4280,6 +4378,7 @@ export function attach(
4378
}
4379
}
4380
} finally {
4381
+ isInFocusedActivity = stashedIsInActivitySlice;
4382
if (newInstance !== null) {
4383
reconcilingParent = stashedParent;
4384
previouslyReconciledSibling = stashedPrevious;
@@ -4311,6 +4410,7 @@ export function attach(
4410
const stashedSuspenseParent = reconcilingParentSuspenseNode;
4411
const stashedSuspensePrevious = previouslyReconciledSiblingSuspenseNode;
4412
const stashedSuspenseRemaining = remainingReconcilingChildrenSuspenseNodes;
4413
+ const stashedIsInActivitySlice = isInFocusedActivity;
4414
const previousSuspendedBy = instance.suspendedBy;
4415
// Push a new DevTools instance parent while reconciling this subtree.
4416
reconcilingParent = instance;
@@ -4329,6 +4429,19 @@ export function attach(
4429
shouldPopSuspenseNode = true;
4430
}
4431
4432
+ if (focusedActivity !== null) {
4433
+ if (instance.id === focusedActivityID) {
4434
+ isInFocusedActivity = true;
4435
+ } else if (
4436
+ instance.kind === FIBER_INSTANCE &&
4437
+ instance.data !== null &&
4438
+ instance.data.tag === ActivityComponent
4439
+ ) {
4440
+ // Filtering nested Activity components inside the focused activity
4441
+ // is done in the frontend.
4442
+ }
4443
+ }
4444
+
4445
try {
4446
// Unmount the remaining set.
4447
if (
@@ -4379,6 +4492,7 @@ export function attach(
4492
previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
4493
remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
4494
}
4495
+ isInFocusedActivity = stashedIsInActivitySlice;
4496
}
4497
if (instance.kind === FIBER_INSTANCE) {
4498
recordUnmount(instance);
@@ -5059,6 +5173,7 @@ export function attach(
5173
const stashedSuspenseParent = reconcilingParentSuspenseNode;
5174
const stashedSuspensePrevious = previouslyReconciledSiblingSuspenseNode;
5175
const stashedSuspenseRemaining = remainingReconcilingChildrenSuspenseNodes;
5176
+ const stashedIsInActivitySlice = isInFocusedActivity;
5177
let updateFlags = NoUpdate;
5178
let shouldMeasureSuspenseNode = false;
5179
let shouldPopSuspenseNode = false;
@@ -5098,6 +5213,15 @@ export function attach(
5213
shouldMeasureSuspenseNode = true;
5214
shouldPopSuspenseNode = true;
5215
}
5216
+
5217
+ if (focusedActivity !== null) {
5218
+ if (fiberInstance.id === focusedActivityID) {
5219
+ isInFocusedActivity = true;
5220
+ } else if (nextFiber.tag === ActivityComponent) {
5221
+ // Filtering nested Activity components inside the focused activity
5222
+ // is done in the frontend.
5223
+ }
5224
+ }
5225
}
5226
try {
5227
trackDebugInfoFromLazyType(nextFiber);
@@ -5522,6 +5646,7 @@ export function attach(
5646
previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
5647
remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
5648
}
5649
+ isInFocusedActivity = stashedIsInActivitySlice;
5650
}
5651
}
5652
}
packages/react-devtools-shared/src/constants.js
+1
@@ -29,6 +29,7 @@ export const SUSPENSE_TREE_OPERATION_REMOVE = 9;
29
export const SUSPENSE_TREE_OPERATION_REORDER_CHILDREN = 10;
30
export const SUSPENSE_TREE_OPERATION_RESIZE = 11;
31
export const SUSPENSE_TREE_OPERATION_SUSPENDERS = 12;
32
+export const TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE = 13;
33
34
export const PROFILING_FLAG_BASIC_SUPPORT /*. */ = 0b001;
35
export const PROFILING_FLAG_TIMELINE_SUPPORT /* */ = 0b010;
packages/react-devtools-shared/src/devtools/store.js
+94
-6
@@ -21,13 +21,18 @@ import {
21
TREE_OPERATION_SET_SUBTREE_MODE,
22
TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS,
23
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
24
+ TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE,
25
SUSPENSE_TREE_OPERATION_ADD,
26
SUSPENSE_TREE_OPERATION_REMOVE,
27
SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
28
SUSPENSE_TREE_OPERATION_RESIZE,
29
SUSPENSE_TREE_OPERATION_SUSPENDERS,
30
} from '../constants';
30
-import {ElementTypeRoot} from '../frontend/types';
31
+import {
32
+ ElementTypeRoot,
33
+ ElementTypeActivity,
34
+ ComponentFilterActivitySlice,
35
+} from '../frontend/types';
36
import {
37
getSavedComponentFilters,
38
setSavedComponentFilters,
@@ -144,7 +149,13 @@ export default class Store extends EventEmitter<{
149
hookSettings: [$ReadOnly<DevToolsHookSettings>],
150
hostInstanceSelected: [Element['id']],
151
settingsUpdated: [$ReadOnly<DevToolsHookSettings>],
147
- mutated: [[Array<Element['id']>, Map<Element['id'], Element['id']>]],
152
+ mutated: [
153
+ [
154
+ Array<Element['id']>,
155
+ Map<Element['id'], Element['id']>,
156
+ Element['id'] | null,
157
+ ],
158
+ ],
159
recordChangeDescriptions: [],
160
roots: [],
161
rootSupportsBasicProfiling: [],
@@ -1156,7 +1167,7 @@ export default class Store extends EventEmitter<{
1167
// The Tree context's search reducer expects an explicit list of ids for nodes that were added or removed.
1168
// In this case, we can pass it empty arrays since nodes in a collapsed tree are still there (just hidden).
1169
// Updating the selected search index later may require auto-expanding a collapsed subtree though.
1159
- this.emit('mutated', [[], new Map()]);
1170
+ this.emit('mutated', [[], new Map(), null]);
1171
}
1172
}
1173
}
@@ -1225,10 +1236,11 @@ export default class Store extends EventEmitter<{
1236
1237
const addedElementIDs: Array<number> = [];
1238
// This is a mapping of removed ID -> parent ID:
1239
+ // We'll use the parent ID to adjust selection if it gets deleted.
1240
const removedElementIDs: Map<number, number> = new Map();
1241
const removedSuspenseIDs: Map<SuspenseNode['id'], SuspenseNode['id']> =
1242
new Map();
1231
- // We'll use the parent ID to adjust selection if it gets deleted.
1243
+ let nextActivitySliceID = null;
1244
1245
let i = 2;
1246
@@ -1962,6 +1974,11 @@ export default class Store extends EventEmitter<{
1974
1975
break;
1976
}
1977
+ case TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE: {
1978
+ i++;
1979
+ nextActivitySliceID = operations[i++];
1980
+ break;
1981
+ }
1982
default:
1983
this._throwAndEmitError(
1984
new UnsupportedBridgeOperationError(
@@ -2060,9 +2077,80 @@ export default class Store extends EventEmitter<{
2077
console.groupEnd();
2078
}
2079
2063
- this.emit('mutated', [addedElementIDs, removedElementIDs]);
2080
+ if (nextActivitySliceID !== null && nextActivitySliceID !== 0) {
2081
+ let didCollapse = false;
2082
+ // The backend filtered everything above the Activity slice.
2083
+ // We need to hide everything below the Activity slice by collapsing
2084
+ // the Activities that are descendants of the next Activity slice.
2085
+ const nextActivitySlice = this._idToElement.get(nextActivitySliceID);
2086
+ if (nextActivitySlice === undefined) {
2087
+ throw new Error('Next Activity slice not found in Store.');
2088
+ }
2089
+
2090
+ for (let j = 0; j < nextActivitySlice.children.length; j++) {
2091
+ didCollapse ||= this._collapseActivitiesRecursively(
2092
+ nextActivitySlice.children[j],
2093
+ );
2094
+ }
2095
+
2096
+ if (didCollapse) {
2097
+ let weightAcrossRoots = 0;
2098
+ this._roots.forEach(rootID => {
2099
+ const {weight} = ((this.getElementByID(rootID): any): Element);
2100
+ weightAcrossRoots += weight;
2101
+ });
2102
+ this._weightAcrossRoots = weightAcrossRoots;
2103
+ }
2104
+ }
2105
+
2106
+ for (let j = 0; j < this._componentFilters.length; j++) {
2107
+ const filter = this._componentFilters[j];
2108
+ // If we're focusing an Activity, IDs may have changed.
2109
+ if (filter.type === ComponentFilterActivitySlice) {
2110
+ if (nextActivitySliceID === null || nextActivitySliceID === 0) {
2111
+ filter.isValid = false;
2112
+ } else {
2113
+ filter.activityID = nextActivitySliceID;
2114
+ }
2115
+ }
2116
+ }
2117
+
2118
+ this.emit('mutated', [
2119
+ addedElementIDs,
2120
+ removedElementIDs,
2121
+ nextActivitySliceID,
2122
+ ]);
2123
};
2124
2125
+ _collapseActivitiesRecursively(elementID: number): boolean {
2126
+ let didMutate = false;
2127
+ const element = this._idToElement.get(elementID);
2128
+ if (element === undefined) {
2129
+ throw new Error('Element not found in Store.');
2130
+ }
2131
+
2132
+ if (element.type === ElementTypeActivity) {
2133
+ if (!element.isCollapsed) {
2134
+ element.isCollapsed = true;
2135
+
2136
+ const weightDelta = 1 - element.weight;
2137
+
2138
+ let parentElement = this._idToElement.get(element.parentID);
2139
+ while (parentElement !== undefined) {
2140
+ parentElement.weight += weightDelta;
2141
+ parentElement = this._idToElement.get(parentElement.parentID);
2142
+ }
2143
+ return true;
2144
+ }
2145
+ return false;
2146
+ }
2147
+
2148
+ for (let i = 0; i < element.children.length; i++) {
2149
+ didMutate ||= this._collapseActivitiesRecursively(element.children[i]);
2150
+ }
2151
+ return didMutate;
2152
+ }
2153
+
2154
// Certain backends save filters on a per-domain basis.
2155
// In order to prevent filter preferences and applied filters from being out of sync,
2156
// this message enables the backend to override the frontend's current ("saved") filters.
@@ -2228,7 +2316,7 @@ export default class Store extends EventEmitter<{
2316
2317
if (previousStatus !== status) {
2318
// Propagate to subscribers, although tree state has not changed
2231
- this.emit('mutated', [[], new Map()]);
2319
+ this.emit('mutated', [[], new Map(), null]);
2320
}
2321
}
2322
packages/react-devtools-shared/src/devtools/views/Components/ActivitySlice.css
new
+28
@@ -0,0 +1,28 @@
1
+.ActivitySlice {
2
+ max-width: 100%;
3
+ overflow-x: auto;
4
+ flex: 1;
5
+ display: flex;
6
+ align-items: center;
7
+ position: relative;
8
+}
9
+
10
+.ActivitySliceButton {
11
+ color: var(--color-button-active);
12
+ font-family: var(--font-family-monospace);
13
+ font-size: var(--font-size-monospace-normal);
14
+}
15
+
16
+.Bar {
17
+ display: flex;
18
+ flex: 1 1 auto;
19
+ overflow-x: auto;
20
+}
21
+
22
+.VRule {
23
+ flex: 0 0 auto;
24
+ height: 20px;
25
+ width: 1px;
26
+ background-color: var(--color-border);
27
+ margin: 0 0.5rem;
28
+}
packages/react-devtools-shared/src/devtools/views/Components/ActivitySlice.js
new
+52
@@ -0,0 +1,52 @@
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
+ * @flow
8
+ */
9
+import * as React from 'react';
10
+import {startTransition, useContext} from 'react';
11
+import Button from '../Button';
12
+import ButtonIcon from '../ButtonIcon';
13
+import {StoreContext} from '../context';
14
+import {useChangeActivitySliceAction} from '../SuspenseTab/ActivityList';
15
+import {TreeDispatcherContext, TreeStateContext} from './TreeContext';
16
+import styles from './ActivitySlice.css';
17
+
18
+export default function ActivitySlice(): React.Node {
19
+ const dispatch = useContext(TreeDispatcherContext);
20
+ const {activityID} = useContext(TreeStateContext);
21
+ const store = useContext(StoreContext);
22
+
23
+ const activity =
24
+ activityID === null ? null : store.getElementByID(activityID);
25
+ const name = activity ? activity.nameProp : null;
26
+
27
+ const changeActivitySliceAction = useChangeActivitySliceAction();
28
+
29
+ return (
30
+ <div className={styles.ActivitySlice}>
31
+ <div className={styles.Bar}>
32
+ <Button
33
+ className={styles.ActivitySliceButton}
34
+ onClick={dispatch.bind(null, {
35
+ type: 'SELECT_ELEMENT_BY_ID',
36
+ payload: activityID,
37
+ })}>
38
+ "{name || 'Unknown'}"
39
+ </Button>
40
+ </div>
41
+ <div className={styles.VRule} />
42
+ <Button
43
+ onClick={startTransition.bind(
44
+ null,
45
+ changeActivitySliceAction.bind(null, null),
46
+ )}
47
+ title="Back to tree view">
48
+ <ButtonIcon type="close" />
49
+ </Button>
50
+ </div>
51
+ );
52
+}
packages/react-devtools-shared/src/devtools/views/Components/Element.js
+11
-4
@@ -8,8 +8,9 @@
8
*/
9
10
import * as React from 'react';
11
-import {Fragment, useContext, useMemo, useState} from 'react';
11
+import {Fragment, startTransition, useContext, useMemo, useState} from 'react';
12
import Store from 'react-devtools-shared/src/devtools/store';
13
+import {ElementTypeActivity} from 'react-devtools-shared/src/frontend/types';
14
import ButtonIcon from '../ButtonIcon';
15
import {TreeDispatcherContext, TreeStateContext} from './TreeContext';
16
import {StoreContext} from '../context';
@@ -25,6 +26,7 @@ import styles from './Element.css';
26
import Icon from '../Icon';
27
import {useChangeOwnerAction} from './OwnersListContext';
28
import Tooltip from './reach-ui/tooltip';
29
+import {useChangeActivitySliceAction} from '../SuspenseTab/ActivityList';
30
31
type Props = {
32
data: ItemData,
@@ -65,6 +67,7 @@ export default function Element({data, index, style}: Props): React.Node {
67
}>(errorsAndWarningsSubscription);
68
69
const changeOwnerAction = useChangeOwnerAction();
70
+ const changeActivitySliceAction = useChangeActivitySliceAction();
71
72
// Handle elements that are removed from the tree while an async render is in progress.
73
if (element == null) {
@@ -75,9 +78,13 @@ export default function Element({data, index, style}: Props): React.Node {
78
}
79
80
const handleDoubleClick = () => {
78
- if (id !== null) {
79
- changeOwnerAction(id);
80
- }
81
+ startTransition(() => {
82
+ if (element.type === ElementTypeActivity) {
83
+ changeActivitySliceAction(element.id);
84
+ } else {
85
+ changeOwnerAction(element.id);
86
+ }
87
+ });
88
};
89
90
// $FlowFixMe[missing-local-annot]
packages/react-devtools-shared/src/devtools/views/Components/Tree.js
+24
-2
@@ -11,6 +11,7 @@ import * as React from 'react';
11
import {
12
Fragment,
13
Suspense,
14
+ startTransition,
15
useCallback,
16
useContext,
17
useEffect,
@@ -37,7 +38,10 @@ import ButtonIcon from '../ButtonIcon';
38
import Button from '../Button';
39
import {logEvent} from 'react-devtools-shared/src/Logger';
40
import {useExtensionComponentsPanelVisibility} from 'react-devtools-shared/src/frontend/hooks/useExtensionComponentsPanelVisibility';
41
+import {ElementTypeActivity} from 'react-devtools-shared/src/frontend/types';
42
import {useChangeOwnerAction} from './OwnersListContext';
43
+import {useChangeActivitySliceAction} from '../SuspenseTab/ActivityList';
44
+import ActivitySlice from './ActivitySlice';
45
46
// Indent for each node at level N, compared to node at level N - 1.
47
const INDENTATION_SIZE = 10;
@@ -72,6 +76,7 @@ function calculateInitialScrollOffset(
76
export default function Tree(): React.Node {
77
const dispatch = useContext(TreeDispatcherContext);
78
const {
79
+ activityID,
80
numElements,
81
ownerID,
82
searchIndex,
@@ -302,6 +307,7 @@ export default function Tree(): React.Node {
307
const handleBlur = useCallback(() => setTreeFocused(false), []);
308
const handleFocus = useCallback(() => setTreeFocused(true), []);
309
310
+ const changeActivitySliceAction = useChangeActivitySliceAction();
311
const changeOwnerAction = useChangeOwnerAction();
312
const handleKeyPress = useCallback(
313
(event: $FlowFixMe) => {
@@ -309,7 +315,17 @@ export default function Tree(): React.Node {
315
case 'Enter':
316
case ' ':
317
if (inspectedElementID !== null) {
312
- changeOwnerAction(inspectedElementID);
318
+ const inspectedElement = store.getElementByID(inspectedElementID);
319
+ startTransition(() => {
320
+ if (
321
+ inspectedElement !== null &&
322
+ inspectedElement.type === ElementTypeActivity
323
+ ) {
324
+ changeActivitySliceAction(inspectedElementID);
325
+ } else {
326
+ changeOwnerAction(inspectedElementID);
327
+ }
328
+ });
329
}
330
break;
331
default:
@@ -444,7 +460,13 @@ export default function Tree(): React.Node {
460
</Fragment>
461
)}
462
<Suspense fallback={<Loading />}>
447
- {ownerID !== null ? <OwnersStack /> : <ComponentSearchInput />}
463
+ {ownerID !== null ? (
464
+ <OwnersStack />
465
+ ) : activityID !== null ? (
466
+ <ActivitySlice />
467
+ ) : (
468
+ <ComponentSearchInput />
469
+ )}
470
</Suspense>
471
{ownerID === null && (errors > 0 || warnings > 0) && (
472
<React.Fragment>
packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js
+45
-7
@@ -57,6 +57,9 @@ export type StateContext = {
57
ownerID: number | null,
58
ownerFlatTree: Array<Element> | null,
59
60
+ // Activity slice
61
+ activityID: Element['id'] | null,
62
+
63
// Inspection element panel
64
inspectedElementID: number | null,
65
inspectedElementIndex: number | null,
@@ -70,7 +73,7 @@ type ACTION_GO_TO_PREVIOUS_SEARCH_RESULT = {
73
};
74
type ACTION_HANDLE_STORE_MUTATION = {
75
type: 'HANDLE_STORE_MUTATION',
73
- payload: [Array<number>, Map<number, number>],
76
+ payload: [Array<number>, Map<number, number>, null | Element['id']],
77
};
78
type ACTION_RESET_OWNER_STACK = {
79
type: 'RESET_OWNER_STACK',
@@ -167,6 +170,9 @@ type State = {
170
ownerID: number | null,
171
ownerFlatTree: Array<Element> | null,
172
173
+ // Activity slice
174
+ activityID: Element['id'] | null,
175
+
176
// Inspection element panel
177
inspectedElementID: number | null,
178
inspectedElementIndex: number | null,
@@ -794,6 +800,33 @@ function reduceOwnersState(store: Store, state: State, action: Action): State {
800
};
801
}
802
803
+function reduceActivityState(
804
+ store: Store,
805
+ state: State,
806
+ action: Action,
807
+): State {
808
+ switch (action.type) {
809
+ case 'HANDLE_STORE_MUTATION':
810
+ let {activityID} = state;
811
+ const [, , activitySliceIDChange] = action.payload;
812
+ if (activitySliceIDChange === 0 && activityID !== null) {
813
+ activityID = null;
814
+ } else if (
815
+ activitySliceIDChange !== null &&
816
+ activitySliceIDChange !== activityID
817
+ ) {
818
+ activityID = activitySliceIDChange;
819
+ }
820
+ if (activityID !== state.activityID) {
821
+ return {
822
+ ...state,
823
+ activityID,
824
+ };
825
+ }
826
+ }
827
+ return state;
828
+}
829
+
830
type Props = {
831
children: React$Node,
832
@@ -828,6 +861,9 @@ function getInitialState({
861
ownerID: defaultOwnerID == null ? null : defaultOwnerID,
862
ownerFlatTree: null,
863
864
+ // Activity slice
865
+ activityID: null,
866
+
867
// Inspection element panel
868
inspectedElementID:
869
defaultInspectedElementID != null
@@ -882,6 +918,7 @@ function TreeContextController({
918
state = reduceTreeState(store, state, action);
919
state = reduceSearchState(store, state, action);
920
state = reduceOwnersState(store, state, action);
921
+ state = reduceActivityState(store, state, action);
922
923
// TODO(hoxyq): review
924
// If the selected ID is in a collapsed subtree, reset the selected index to null.
@@ -950,13 +987,14 @@ function TreeContextController({
987
988
// Mutations to the underlying tree may impact this context (e.g. search results, selection state).
989
useEffect(() => {
953
- const handleStoreMutated = ([addedElementIDs, removedElementIDs]: [
954
- Array<number>,
955
- Map<number, number>,
956
- ]) => {
990
+ const handleStoreMutated = ([
991
+ addedElementIDs,
992
+ removedElementIDs,
993
+ activitySliceIDChange,
994
+ ]: [Array<number>, Map<number, number>, null | Element['id']]) => {
995
dispatch({
996
type: 'HANDLE_STORE_MUTATION',
959
- payload: [addedElementIDs, removedElementIDs],
997
+ payload: [addedElementIDs, removedElementIDs, activitySliceIDChange],
998
});
999
};
1000
@@ -967,7 +1005,7 @@ function TreeContextController({
1005
// It would only impact the search state, which is unlikely to exist yet at this point.
1006
dispatch({
1007
type: 'HANDLE_STORE_MUTATION',
970
- payload: [[], new Map()],
1008
+ payload: [[], new Map(), null],
1009
});
1010
}
1011
packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js
+15
@@ -16,6 +16,7 @@ import {
16
TREE_OPERATION_SET_SUBTREE_MODE,
17
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
18
TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS,
19
+ TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE,
20
SUSPENSE_TREE_OPERATION_ADD,
21
SUSPENSE_TREE_OPERATION_REMOVE,
22
SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
@@ -475,6 +476,20 @@ function updateTree(
476
break;
477
}
478
479
+ case TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE: {
480
+ i++;
481
+ const activitySliceIDChange = operations[i++];
482
+ if (__DEBUG__) {
483
+ debug(
484
+ 'Applied activity slice change',
485
+ activitySliceIDChange === 0
486
+ ? 'Reset applied activity slice'
487
+ : `Changed to activity slice ID ${activitySliceIDChange}`,
488
+ );
489
+ }
490
+ break;
491
+ }
492
+
493
default:
494
throw Error(`Unsupported Bridge operation "${operation}"`);
495
}
packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js
+42
-24
@@ -29,6 +29,7 @@ import {
29
ComponentFilterHOC,
30
ComponentFilterLocation,
31
ComponentFilterEnvironmentName,
32
+ ComponentFilterActivitySlice,
33
ElementTypeClass,
34
ElementTypeContext,
35
ElementTypeFunction,
@@ -171,6 +172,8 @@ export default function ComponentsSettings({
172
isValid: true,
173
value: 'Client',
174
};
175
+ } else if (type === ComponentFilterActivitySlice) {
176
+ // TODO: Allow changing type
177
}
178
}
179
return cloned;
@@ -364,34 +367,39 @@ export default function ComponentsSettings({
367
{componentFilters.map((componentFilter, index) => (
368
<tr className={styles.TableRow} key={index}>
369
<td className={styles.TableCell}>
367
- <Toggle
368
- className={
369
- componentFilter.isValid !== false
370
- ? ''
371
- : styles.InvalidRegExp
372
- }
373
- isChecked={componentFilter.isEnabled}
374
- onChange={isEnabled =>
375
- toggleFilterIsEnabled(componentFilter, isEnabled)
376
- }
377
- title={
378
- componentFilter.isValid === false
379
- ? 'Filter invalid'
380
- : componentFilter.isEnabled
381
- ? 'Filter enabled'
382
- : 'Filter disabled'
383
- }>
384
- <ToggleIcon
385
- isEnabled={componentFilter.isEnabled}
386
- isValid={
387
- componentFilter.isValid == null ||
388
- componentFilter.isValid === true
370
+ {componentFilter.type !== ComponentFilterActivitySlice && (
371
+ <Toggle
372
+ className={
373
+ componentFilter.isValid !== false
374
+ ? ''
375
+ : styles.InvalidRegExp
376
}
390
- />
391
- </Toggle>
377
+ isChecked={componentFilter.isEnabled}
378
+ onChange={isEnabled =>
379
+ toggleFilterIsEnabled(componentFilter, isEnabled)
380
+ }
381
+ title={
382
+ componentFilter.isValid === false
383
+ ? 'Filter invalid'
384
+ : componentFilter.isEnabled
385
+ ? 'Filter enabled'
386
+ : 'Filter disabled'
387
+ }>
388
+ <ToggleIcon
389
+ isEnabled={componentFilter.isEnabled}
390
+ isValid={
391
+ componentFilter.isValid == null ||
392
+ componentFilter.isValid === true
393
+ }
394
+ />
395
+ </Toggle>
396
+ )}
397
</td>
398
<td className={styles.TableCell}>
399
<select
400
+ disabled={
401
+ componentFilter.type === ComponentFilterActivitySlice
402
+ }
403
value={componentFilter.type}
404
onChange={({currentTarget}) =>
405
changeFilterType(
@@ -413,6 +421,11 @@ export default function ComponentsSettings({
421
environment
422
</option>
423
)}
424
+ {componentFilter.type === ComponentFilterActivitySlice && (
425
+ <option value={ComponentFilterActivitySlice}>
426
+ component
427
+ </option>
428
+ )}
429
</select>
430
</td>
431
<td className={styles.TableCell}>
@@ -422,6 +435,8 @@ export default function ComponentsSettings({
435
{(componentFilter.type === ComponentFilterLocation ||
436
componentFilter.type === ComponentFilterDisplayName) &&
437
'matches'}
438
+ {componentFilter.type === ComponentFilterActivitySlice &&
439
+ 'within'}
440
</td>
441
<td className={styles.TableCell}>
442
{componentFilter.type === ComponentFilterElementType && (
@@ -487,6 +502,9 @@ export default function ComponentsSettings({
502
))}
503
</select>
504
)}
505
+ {componentFilter.type === ComponentFilterActivitySlice && (
506
+ <span>Activity Slice</span>
507
+ )}
508
</td>
509
<td className={styles.TableCell}>
510
<Button
packages/react-devtools-shared/src/devtools/views/SuspenseTab/ActivityList.css
new
+45
@@ -0,0 +1,45 @@
1
+.ActivityList {
2
+ cursor: default;
3
+ list-style-type: none;
4
+ margin: 0;
5
+ padding: 0;
6
+}
7
+
8
+.ActivityList[data-pending-activity-slice-selection="true"] {
9
+ cursor: wait;
10
+}
11
+
12
+.ActivityList:focus {
13
+ outline: none;
14
+}
15
+
16
+.ActivityListItem {
17
+ color: var(--color-component-name);
18
+ padding: 0 0.25rem;
19
+ user-select: none;
20
+}
21
+
22
+.ActivityListItem:hover {
23
+ background-color: var(--color-background-hover);
24
+}
25
+
26
+.ActivityListItem[aria-selected="true"] {
27
+ background-color: var(--color-background-inactive);
28
+}
29
+
30
+.ActivityList:focus .ActivityListItem[aria-selected="true"] {
31
+ background-color: var(--color-background-selected);
32
+ color: var(--color-text-selected);
33
+
34
+ /* Invert colors */
35
+ --color-component-name: var(--color-component-name-inverted);
36
+ --color-text: var(--color-text-selected);
37
+ --color-component-badge-background: var(
38
+ --color-component-badge-background-inverted
39
+ );
40
+ --color-forget-badge-background: var(--color-forget-badge-background-inverted);
41
+ --color-component-badge-count: var(--color-component-badge-count-inverted);
42
+ --color-attribute-name: var(--color-attribute-name-inverted);
43
+ --color-attribute-value: var(--color-attribute-value-inverted);
44
+ --color-expand-collapse-toggle: var(--color-component-name-inverted);
45
+}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/ActivityList.js
new
+173
@@ -0,0 +1,173 @@
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
+ * @flow
8
+ */
9
+import type {
10
+ Element,
11
+ ActivitySliceFilter,
12
+ ComponentFilter,
13
+} from 'react-devtools-shared/src/frontend/types';
14
+import typeof {
15
+ SyntheticMouseEvent,
16
+ SyntheticKeyboardEvent,
17
+} from 'react-dom-bindings/src/events/SyntheticEvent';
18
+
19
+import * as React from 'react';
20
+import {useContext, useTransition} from 'react';
21
+import {ComponentFilterActivitySlice} from 'react-devtools-shared/src/frontend/types';
22
+import styles from './ActivityList.css';
23
+import {
24
+ TreeStateContext,
25
+ TreeDispatcherContext,
26
+} from '../Components/TreeContext';
27
+import {useHighlightHostInstance} from '../hooks';
28
+import {StoreContext} from '../context';
29
+
30
+export function useChangeActivitySliceAction(): (
31
+ id: Element['id'] | null,
32
+) => void {
33
+ const store = useContext(StoreContext);
34
+
35
+ function changeActivitySliceAction(activityID: Element['id'] | null) {
36
+ const nextFilters: ComponentFilter[] = [];
37
+ // Remove any existing activity slice filter
38
+ for (let i = 0; i < store.componentFilters.length; i++) {
39
+ const filter = store.componentFilters[i];
40
+ if (filter.type !== ComponentFilterActivitySlice) {
41
+ nextFilters.push(filter);
42
+ }
43
+ }
44
+
45
+ if (activityID !== null) {
46
+ const rendererID = store.getRendererIDForElement(activityID);
47
+ if (rendererID === null) {
48
+ throw new Error('Expected to find renderer.');
49
+ }
50
+ const activityFilter: ActivitySliceFilter = {
51
+ type: ComponentFilterActivitySlice,
52
+ activityID,
53
+ rendererID,
54
+ isValid: true,
55
+ isEnabled: true,
56
+ };
57
+ nextFilters.push(activityFilter);
58
+ }
59
+ store.componentFilters = nextFilters;
60
+ }
61
+
62
+ return changeActivitySliceAction;
63
+}
64
+
65
+export default function ActivityList({
66
+ activities,
67
+}: {
68
+ activities: $ReadOnlyArray<Element>,
69
+}): React$Node {
70
+ const {inspectedElementID} = useContext(TreeStateContext);
71
+ const treeDispatch = useContext(TreeDispatcherContext);
72
+ // TODO: Derive from inspected element
73
+ const selectedActivityID = inspectedElementID;
74
+ const {highlightHostInstance, clearHighlightHostInstance} =
75
+ useHighlightHostInstance();
76
+
77
+ const [isPendingActivitySliceSelection, startActivitySliceSelection] =
78
+ useTransition();
79
+ const changeActivitySliceAction = useChangeActivitySliceAction();
80
+
81
+ function handleKeyDown(event: SyntheticKeyboardEvent) {
82
+ // TODO: Implement keyboard navigation
83
+ switch (event.key) {
84
+ case 'Enter':
85
+ case ' ':
86
+ if (inspectedElementID !== null) {
87
+ startActivitySliceSelection(() => {
88
+ changeActivitySliceAction(inspectedElementID);
89
+ });
90
+ }
91
+ event.preventDefault();
92
+ break;
93
+ case 'Home':
94
+ treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: activities[0].id});
95
+ event.preventDefault();
96
+ break;
97
+ case 'End':
98
+ treeDispatch({
99
+ type: 'SELECT_ELEMENT_BY_ID',
100
+ payload: activities[activities.length - 1].id,
101
+ });
102
+ event.preventDefault();
103
+ break;
104
+ case 'ArrowUp': {
105
+ const currentIndex = activities.findIndex(
106
+ activity => activity.id === selectedActivityID,
107
+ );
108
+ if (currentIndex !== undefined) {
109
+ const nextIndex =
110
+ (currentIndex + activities.length - 1) % activities.length;
111
+
112
+ treeDispatch({
113
+ type: 'SELECT_ELEMENT_BY_ID',
114
+ payload: activities[nextIndex].id,
115
+ });
116
+ }
117
+ event.preventDefault();
118
+ break;
119
+ }
120
+ case 'ArrowDown': {
121
+ const currentIndex = activities.findIndex(
122
+ activity => activity.id === selectedActivityID,
123
+ );
124
+ if (currentIndex !== undefined) {
125
+ const nextIndex = (currentIndex + 1) % activities.length;
126
+
127
+ treeDispatch({
128
+ type: 'SELECT_ELEMENT_BY_ID',
129
+ payload: activities[nextIndex].id,
130
+ });
131
+ }
132
+ event.preventDefault();
133
+ break;
134
+ }
135
+ default:
136
+ break;
137
+ }
138
+ }
139
+
140
+ function handleClick(id: Element['id'], event: SyntheticMouseEvent) {
141
+ event.preventDefault();
142
+ treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id});
143
+ }
144
+
145
+ function handleDoubleClick() {
146
+ if (inspectedElementID !== null) {
147
+ changeActivitySliceAction(inspectedElementID);
148
+ }
149
+ }
150
+
151
+ return (
152
+ <ol
153
+ role="listbox"
154
+ className={styles.ActivityList}
155
+ data-pending-activity-slice-selection={isPendingActivitySliceSelection}
156
+ tabIndex={0}
157
+ onKeyDown={handleKeyDown}>
158
+ {activities.map(activity => (
159
+ <li
160
+ key={activity.id}
161
+ role="option"
162
+ aria-selected={activity.id === selectedActivityID ? 'true' : 'false'}
163
+ className={styles.ActivityListItem}
164
+ onClick={handleClick.bind(null, activity.id)}
165
+ onDoubleClick={handleDoubleClick}
166
+ onPointerOver={highlightHostInstance.bind(null, activity.id, false)}
167
+ onPointerLeave={clearHighlightHostInstance}>
168
+ {activity.nameProp}
169
+ </li>
170
+ ))}
171
+ </ol>
172
+ );
173
+}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css
+2
-3
@@ -91,10 +91,9 @@
91
}
92
}
93
94
-.TreeList {
94
+.ActivityList {
95
flex: 0 0 var(--horizontal-resize-tree-list-percentage);
96
border-right: 1px solid var(--color-border);
97
- padding: 0.25rem;
97
overflow: auto;
98
}
99
@@ -142,4 +141,4 @@
141
142
.SuspenseTreeViewFooterButtons {
143
padding: 0.25rem;
145
-}
\ No newline at end of file
144
+}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
+19
-6
@@ -6,12 +6,14 @@
6
*
7
* @flow
8
*/
9
+import type {Element} from 'react-devtools-shared/src/frontend/types';
10
11
import * as React from 'react';
12
import {
13
useContext,
14
useEffect,
15
useLayoutEffect,
16
+ useMemo,
17
useReducer,
18
useRef,
19
Fragment,
@@ -30,7 +32,7 @@ import styles from './SuspenseTab.css';
32
import SuspenseBreadcrumbs from './SuspenseBreadcrumbs';
33
import SuspenseRects from './SuspenseRects';
34
import SuspenseTimeline from './SuspenseTimeline';
33
-import SuspenseTreeList from './SuspenseTreeList';
35
+import ActivityList from './ActivityList';
36
import {
37
SuspenseTreeDispatcherContext,
38
SuspenseTreeStateContext,
@@ -270,6 +272,17 @@ function SynchronizedScrollContainer({
272
);
273
}
274
275
+// TODO: Get this from the store directly.
276
+// The backend needs to keep a separate tree so that resuspending keeps Activity around.
277
+function useActivities(): $ReadOnlyArray<Element> {
278
+ const activities = useMemo(() => {
279
+ const items: Array<Element> = [];
280
+ return items;
281
+ }, []);
282
+
283
+ return activities;
284
+}
285
+
286
function SuspenseTab(_: {}) {
287
const store = useContext(StoreContext);
288
const {hideSettings} = useContext(OptionsContext);
@@ -279,10 +292,10 @@ function SuspenseTab(_: {}) {
292
initLayoutState,
293
);
294
295
+ const activities = useActivities();
296
// If there are no named Activity boundaries, we don't have any tree list and we should hide
283
- // both the panel and the button to toggle it. Since we currently don't support it yet, it's
284
- // always disabled.
285
- const treeListDisabled = true;
297
+ // both the panel and the button to toggle it.
298
+ const treeListDisabled = activities.length === 0;
299
300
const wrapperTreeRef = useRef<null | HTMLElement>(null);
301
const resizeTreeRef = useRef<null | HTMLElement>(null);
@@ -462,10 +475,10 @@ function SuspenseTab(_: {}) {
475
<div className={styles.TreeWrapper} ref={resizeTreeRef}>
476
{treeListDisabled ? null : (
477
<div
465
- className={styles.TreeList}
478
+ className={styles.ActivityList}
479
hidden={treeListHidden}
480
ref={resizeTreeListRef}>
468
- <SuspenseTreeList />
481
+ <ActivityList activities={activities} />
482
</div>
483
)}
484
{treeListDisabled ? null : (
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeList.js
deleted
-14
@@ -1,14 +0,0 @@
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
- * @flow
8
- */
9
-
10
-import * as React from 'react';
11
-
12
-export default function SuspenseTreeList(_: {}): React$Node {
13
- return <div>Activity slices not implemented yet</div>;
14
-}
packages/react-devtools-shared/src/frontend/types.js
+12
-2
@@ -82,8 +82,9 @@ export const ComponentFilterDisplayName = 2;
82
export const ComponentFilterLocation = 3;
83
export const ComponentFilterHOC = 4;
84
export const ComponentFilterEnvironmentName = 5;
85
+export const ComponentFilterActivitySlice = 6;
86
86
-export type ComponentFilterType = 1 | 2 | 3 | 4 | 5;
87
+export type ComponentFilterType = 1 | 2 | 3 | 4 | 5 | 6;
88
89
// Hide all elements of types in this Set.
90
// We hide host components only by default.
@@ -115,11 +116,20 @@ export type EnvironmentNameComponentFilter = {
116
value: string,
117
};
118
119
+export type ActivitySliceFilter = {
120
+ type: 6,
121
+ activityID: Element['id'],
122
+ rendererID: number,
123
+ isValid: boolean,
124
+ isEnabled: boolean,
125
+};
126
+
127
export type ComponentFilter =
128
| BooleanComponentFilter
129
| ElementTypeComponentFilter
130
| RegExpComponentFilter
122
- | EnvironmentNameComponentFilter;
131
+ | EnvironmentNameComponentFilter
132
+ | ActivitySliceFilter;
133
134
export type HookName = string | null;
135
// Map of hook source ("<filename>:<line-number>:<column-number>") to name.
packages/react-devtools-shared/src/utils.js
+27
-9
@@ -33,6 +33,7 @@ import {
33
TREE_OPERATION_SET_SUBTREE_MODE,
34
TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS,
35
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
36
+ TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE,
37
LOCAL_STORAGE_COMPONENT_FILTER_PREFERENCES_KEY,
38
LOCAL_STORAGE_OPEN_IN_EDITOR_URL,
39
LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET,
@@ -47,6 +48,7 @@ import {
48
SUSPENSE_TREE_OPERATION_SUSPENDERS,
49
} from './constants';
50
import {
51
+ ComponentFilterActivitySlice,
52
ComponentFilterElementType,
53
ComponentFilterLocation,
54
ElementTypeHostComponent,
@@ -443,6 +445,16 @@ export function printOperationsArray(operations: Array<number>) {
445
446
break;
447
}
448
+ case TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE: {
449
+ i++;
450
+ const activitySliceIDChange = operations[i + 1];
451
+ logs.push(
452
+ activitySliceIDChange === 0
453
+ ? 'Reset applied activity slice'
454
+ : 'Applied activity slice change to ' + activitySliceIDChange,
455
+ );
456
+ break;
457
+ }
458
default:
459
throw Error(`Unsupported Bridge operation "${operation}"`);
460
}
@@ -468,7 +480,7 @@ export function getSavedComponentFilters(): Array<ComponentFilter> {
480
);
481
if (raw != null) {
482
const parsedFilters: Array<ComponentFilter> = JSON.parse(raw);
471
- return filterOutLocationComponentFilters(parsedFilters);
483
+ return persistableComponentFilters(parsedFilters);
484
}
485
} catch (error) {}
486
return getDefaultComponentFilters();
@@ -479,16 +491,11 @@ export function setSavedComponentFilters(
491
): void {
492
localStorageSetItem(
493
LOCAL_STORAGE_COMPONENT_FILTER_PREFERENCES_KEY,
482
- JSON.stringify(filterOutLocationComponentFilters(componentFilters)),
494
+ JSON.stringify(persistableComponentFilters(componentFilters)),
495
);
496
}
497
486
-// Following __debugSource removal from Fiber, the new approach for finding the source location
487
-// of a component, represented by the Fiber, is based on lazily generating and parsing component stack frames
488
-// To find the original location, React DevTools will perform symbolication, source maps are required for that.
489
-// In order to start filtering Fibers, we need to find location for all of them, which can't be done lazily.
490
-// Eager symbolication can become quite expensive for large applications.
491
-export function filterOutLocationComponentFilters(
498
+export function persistableComponentFilters(
499
componentFilters: Array<ComponentFilter>,
500
): Array<ComponentFilter> {
501
// This is just an additional check to preserve the previous state
@@ -497,7 +504,18 @@ export function filterOutLocationComponentFilters(
504
return componentFilters;
505
}
506
500
- return componentFilters.filter(f => f.type !== ComponentFilterLocation);
507
+ return componentFilters.filter(f => {
508
+ return (
509
+ // Following __debugSource removal from Fiber, the new approach for finding the source location
510
+ // of a component, represented by the Fiber, is based on lazily generating and parsing component stack frames
511
+ // To find the original location, React DevTools will perform symbolication, source maps are required for that.
512
+ // In order to start filtering Fibers, we need to find location for all of them, which can't be done lazily.
513
+ // Eager symbolication can become quite expensive for large applications.
514
+ f.type !== ComponentFilterLocation &&
515
+ // Activity slice filters are based on DevTools instance IDs which do not persist across sessions.
516
+ f.type !== ComponentFilterActivitySlice
517
+ );
518
+ });
519
}
520
521
const vscodeFilepath = 'vscode://file/{path}:{line}:{column}';
packages/react-devtools-shell/src/app/Segments/index.js
new
+96
@@ -0,0 +1,96 @@
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
+ * @flow
8
+ */
9
+
10
+import * as React from 'react';
11
+
12
+function deferred<T>(
13
+ timeoutMS: number,
14
+ resolvedValue: T,
15
+ displayName: string,
16
+): Promise<T> {
17
+ const promise = new Promise<T>(resolve => {
18
+ setTimeout(() => resolve(resolvedValue), timeoutMS);
19
+ });
20
+ (promise as any).displayName = displayName;
21
+
22
+ return promise;
23
+}
24
+
25
+const title = deferred(100, 'Segmented Page Title', 'title');
26
+const content = deferred(
27
+ 400,
28
+ 'This is the content of a segmented page. It loads in multiple parts.',
29
+ 'content',
30
+);
31
+function Page(): React.Node {
32
+ return (
33
+ <article>
34
+ <h1>{title}</h1>
35
+ <p>{content}</p>
36
+ </article>
37
+ );
38
+}
39
+
40
+function InnerSegment({children}: {children: React.Node}): React.Node {
41
+ return (
42
+ <>
43
+ <h3>Inner Segment</h3>
44
+ <React.Suspense name="InnerSegment" fallback={<p>Loading...</p>}>
45
+ <section>{children}</section>
46
+ <p>After inner</p>
47
+ </React.Suspense>
48
+ </>
49
+ );
50
+}
51
+
52
+const cookies = deferred(200, 'Cookies: 🍪🍪🍪', 'cookies');
53
+function OuterSegment({children}: {children: React.Node}): React.Node {
54
+ return (
55
+ <>
56
+ <h2>Outer Segment</h2>
57
+ <React.Suspense name="OuterSegment" fallback={<p>Loading outer</p>}>
58
+ <p>{cookies}</p>
59
+ <div>{children}</div>
60
+ <p>After outer</p>
61
+ </React.Suspense>
62
+ </>
63
+ );
64
+}
65
+
66
+function Root({children}: {children: React.Node}): React.Node {
67
+ return (
68
+ <>
69
+ <h1>Root Segment</h1>
70
+ <React.Suspense name="Root" fallback={<p>Loading root</p>}>
71
+ <main>{children}</main>
72
+ <footer>After root</footer>
73
+ </React.Suspense>
74
+ </>
75
+ );
76
+}
77
+
78
+export default function Segments(): React.Node {
79
+ return (
80
+ <React.Activity name="/" mode="visible">
81
+ <Root>
82
+ <React.Activity name="/outer/" mode="visible">
83
+ <OuterSegment>
84
+ <React.Activity name="/outer/inner" mode="visible">
85
+ <InnerSegment>
86
+ <React.Activity name="/outer/inner/page" mode="visible">
87
+ <Page />
88
+ </React.Activity>
89
+ </InnerSegment>
90
+ </React.Activity>
91
+ </OuterSegment>
92
+ </React.Activity>
93
+ </Root>
94
+ </React.Activity>
95
+ );
96
+}
packages/react-devtools-shell/src/app/index.js
+2
@@ -18,6 +18,7 @@ import ToDoList from './ToDoList';
18
import Toggle from './Toggle';
19
import ErrorBoundaries from './ErrorBoundaries';
20
import PartiallyStrictApp from './PartiallyStrictApp';
21
+import Segments from './Segments';
22
import SuspenseTree from './SuspenseTree';
23
import TraceUpdatesTest from './TraceUpdatesTest';
24
import {ignoreErrors, ignoreLogs, ignoreWarnings} from './console';
@@ -114,6 +115,7 @@ function mountTestApp() {
115
mountApp(DeeplyNestedComponents);
116
mountApp(Iframe);
117
mountApp(TraceUpdatesTest);
118
+ mountApp(Segments);
119
120
if (shouldRenderLegacy) {
121
mountLegacyApp(PartiallyStrictApp);