@samitouri / QOS-React-2 / commits / 5a9921b839

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