diff --git a/packages/react-devtools-shared/src/__tests__/store-test.js b/packages/react-devtools-shared/src/__tests__/store-test.js index 07e8204049752..9b2b2ae54ffd8 100644 --- a/packages/react-devtools-shared/src/__tests__/store-test.js +++ b/packages/react-devtools-shared/src/__tests__/store-test.js @@ -3283,8 +3283,6 @@ describe('Store', () => { `); - console.log('...........................'); - await actAsync(() => { resolve('loaded'); }); diff --git a/packages/react-devtools-shared/src/__tests__/storeComponentFilters-test.js b/packages/react-devtools-shared/src/__tests__/storeComponentFilters-test.js index ce423e94895bb..45b89ecfe3b2b 100644 --- a/packages/react-devtools-shared/src/__tests__/storeComponentFilters-test.js +++ b/packages/react-devtools-shared/src/__tests__/storeComponentFilters-test.js @@ -24,6 +24,16 @@ describe('Store component filters', () => { let utils; let actAsync; + beforeAll(() => { + // JSDDOM doesn't implement getClientRects so we're just faking one for testing purposes + Element.prototype.getClientRects = function (this: Element) { + const textContent = this.textContent; + return [ + new DOMRect(1, 2, textContent.length, textContent.split('\n').length), + ]; + }; + }); + beforeEach(() => { agent = global.agent; bridge = global.bridge; @@ -158,9 +168,9 @@ describe('Store component filters', () => {
- [suspense-root] rects={[]} - - + [suspense-root] rects={[{x:1,y:2,width:7,height:1}, {x:1,y:2,width:6,height:1}]} + + `); await actAsync( @@ -176,9 +186,9 @@ describe('Store component filters', () => {
- [suspense-root] rects={[]} - - + [suspense-root] rects={[{x:1,y:2,width:7,height:1}, {x:1,y:2,width:6,height:1}]} + + `); await actAsync( @@ -194,9 +204,9 @@ describe('Store component filters', () => {
- [suspense-root] rects={[]} - - + [suspense-root] rects={[{x:1,y:2,width:7,height:1}, {x:1,y:2,width:6,height:1}]} + + `); }); @@ -798,8 +808,8 @@ describe('Store component filters', () => {
- [suspense-root] rects={[]} - + [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}]} + `); await actAsync(() => { @@ -814,8 +824,108 @@ describe('Store component filters', () => {
- [suspense-root] rects={[]} - + [suspense-root] rects={[{x:1,y:2,width:0,height:1}, {x:1,y:2,width:0,height:1}]} + + `); + }); + + // @reactVersion >= 19.2 + it('can filter by Activity slices', async () => { + const Activity = React.Activity; + const immediate = Promise.resolve(
Immediate
); + + function Root({children}) { + return ( + + +

Root

+
{children}
+
+
+ ); + } + + function Layout({children}) { + return ( + +

Blog

+
{children}
+
+ ); + } + + function Page() { + return {immediate}; + } + + await actAsync(async () => + render( + + + + + , + ), + ); + + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + ▾ +

+ ▾
+ ▾ + ▾ +

+ ▾
+ ▾ + ▾ +
+ [suspense-root] rects={[{x:1,y:2,width:4,height:1}, {x:1,y:2,width:13,height:1}]} + + + `); + + await actAsync( + async () => + (store.componentFilters = [ + utils.createActivitySliceFilter(store.getElementIDAtIndex(1)), + ]), + ); + + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + ▾ +

+ ▾
+ ▾ + ▸ + [suspense-root] rects={[{x:1,y:2,width:4,height:1}, {x:1,y:2,width:13,height:1}]} + + + `); + + await actAsync(async () => (store.componentFilters = [])); + + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + ▾ +

+ ▾
+ ▾ + ▾ +

+ ▾
+ ▾ + ▾ +
+ [suspense-root] rects={[{x:1,y:2,width:4,height:1}, {x:1,y:2,width:13,height:1}]} + + `); }); }); diff --git a/packages/react-devtools-shared/src/__tests__/utils.js b/packages/react-devtools-shared/src/__tests__/utils.js index c22ac6e05dc11..e84382f6fc0f0 100644 --- a/packages/react-devtools-shared/src/__tests__/utils.js +++ b/packages/react-devtools-shared/src/__tests__/utils.js @@ -328,6 +328,19 @@ export function createLocationFilter( }; } +export function createActivitySliceFilter( + activityID: Element['id'], + isEnabled: boolean = true, +) { + const Types = require('react-devtools-shared/src/frontend/types'); + return { + type: Types.ComponentFilterActivitySlice, + isEnabled, + isValid: true, + activityID: activityID, + }; +} + export function getRendererID(): number { if (global.agent == null) { throw Error('Agent unavailable.'); diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index ddafd014e7ff6..2cd037f78d47d 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -26,6 +26,7 @@ import { ComponentFilterHOC, ComponentFilterLocation, ComponentFilterEnvironmentName, + ComponentFilterActivitySlice, ElementTypeClass, ElementTypeContext, ElementTypeFunction, @@ -53,7 +54,7 @@ import { renamePathInObject, setInObject, utfEncodeString, - filterOutLocationComponentFilters, + persistableComponentFilters, } from 'react-devtools-shared/src/utils'; import { formatConsoleArgumentsToSingleString, @@ -85,6 +86,7 @@ import { TREE_OPERATION_SET_SUBTREE_MODE, TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS, TREE_OPERATION_UPDATE_TREE_BASE_DURATION, + TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE, SUSPENSE_TREE_OPERATION_ADD, SUSPENSE_TREE_OPERATION_REMOVE, SUSPENSE_TREE_OPERATION_REORDER_CHILDREN, @@ -170,6 +172,7 @@ import type { } from '../types'; import type { ComponentFilter, + ActivitySliceFilter, ElementType, Plugins, } from 'react-devtools-shared/src/frontend/types'; @@ -868,6 +871,9 @@ const idToDevToolsInstanceMap: Map< FiberInstance | VirtualInstance, > = new Map(); +let focusedActivityID: null | FiberInstance['id'] = null; +let focusedActivity: null | Fiber = null; + const idToSuspenseNodeMap: Map = new Map(); // Map of canonical HostInstances to the nearest parent DevToolsInstance. @@ -1435,16 +1441,25 @@ export function attach( const hideElementsWithPaths: Set = new Set(); const hideElementsWithTypes: Set = new Set(); const hideElementsWithEnvs: Set = new Set(); + let isInFocusedActivity: boolean = true; // Highlight updates let traceUpdatesEnabled: boolean = false; const traceUpdatesForNodes: Set = new Set(); - function applyComponentFilters(componentFilters: Array) { + function applyComponentFilters( + componentFilters: Array, + nextActivitySlice: null | Fiber, + ) { hideElementsWithTypes.clear(); hideElementsWithDisplayNames.clear(); hideElementsWithPaths.clear(); hideElementsWithEnvs.clear(); + const previousFocusedActivityID = focusedActivityID; + focusedActivityID = null; + focusedActivity = null; + // Consider everything in the slice by default + isInFocusedActivity = true; componentFilters.forEach(componentFilter => { if (!componentFilter.isEnabled) { @@ -1473,6 +1488,25 @@ export function attach( case ComponentFilterEnvironmentName: hideElementsWithEnvs.add(componentFilter.value); break; + case ComponentFilterActivitySlice: + if ( + nextActivitySlice !== null && + nextActivitySlice.tag === ActivityComponent + ) { + focusedActivity = nextActivitySlice; + isInFocusedActivity = false; + if (componentFilter.rendererID !== rendererID) { + // We filtered an Activity from another renderer. + // We need to restore the instance ID since we won't be mounting it + // in this renderer. + focusedActivityID = previousFocusedActivityID; + } + } else { + // We're not filtering by activity slice after all. + // Don't mark the filter as disabled here. + // Otherwise updateComponentFilters() will think no enabled filter was changed. + } + break; default: console.warn( `Invalid component filter type "${componentFilter.type}"`, @@ -1486,11 +1520,9 @@ export function attach( // because they are stored in localStorage within the context of the extension. // Instead it relies on the extension to pass filters through. if (window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ != null) { - const componentFiltersWithoutLocationBasedOnes = - filterOutLocationComponentFilters( - window.__REACT_DEVTOOLS_COMPONENT_FILTERS__, - ); - applyComponentFilters(componentFiltersWithoutLocationBasedOnes); + const restoredComponentFilters: Array = + persistableComponentFilters(window.__REACT_DEVTOOLS_COMPONENT_FILTERS__); + applyComponentFilters(restoredComponentFilters, null); } else { // Unfortunately this feature is not expected to work for React Native for now. // It would be annoying for us to spam YellowBox warnings with unactionable stuff, @@ -1498,7 +1530,7 @@ export function attach( //console.warn('⚛ DevTools: Could not locate saved component filters'); // Fallback to assuming the default filters in this case. - applyComponentFilters(getDefaultComponentFilters()); + applyComponentFilters(getDefaultComponentFilters(), null); } // If necessary, we can revisit optimizing this operation. @@ -1517,6 +1549,22 @@ export function attach( const previousForcedErrors = forceErrorForFibers.size > 0 ? new Map(forceErrorForFibers) : null; + // The ID will be based on the old tree. We need to find the Fiber based on + // that ID before we unmount everything. We set the activity slice ID once + // we mount it again. + let nextFocusedActivity: null | Fiber = null; + let focusedActivityFilter: null | ActivitySliceFilter = null; + for (let i = 0; i < componentFilters.length; i++) { + const filter = componentFilters[i]; + if (filter.type === ComponentFilterActivitySlice && filter.isEnabled) { + focusedActivityFilter = filter; + const instance = idToDevToolsInstanceMap.get(filter.activityID); + if (instance !== undefined && instance.kind === FIBER_INSTANCE) { + nextFocusedActivity = instance.data; + } + } + } + // Recursively unmount all roots. hook.getFiberRoots(rendererID).forEach(root => { const rootInstance = rootToFiberInstanceMap.get(root); @@ -1532,7 +1580,17 @@ export function attach( currentRoot = (null: any); }); - applyComponentFilters(componentFilters); + if ( + nextFocusedActivity !== focusedActivity && + (focusedActivityFilter === null || + focusedActivityFilter.rendererID === rendererID) + ) { + // When we find the applied instance during mount we will send the actual ID. + // Otherwise 0 will indicate that we unfocused the activity slice. + pushOperation(TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE); + pushOperation(0); + } + applyComponentFilters(componentFilters, nextFocusedActivity); // Reset pseudo counters so that new path selections will be persisted. rootDisplayNameCounter.clear(); @@ -1592,6 +1650,13 @@ export function attach( currentRoot = (null: any); }); + // We need to write back the new ID for the focused Fiber. + // Otherwise subsequent filter applications will try to focus based on the old ID. + // This is also relevant to filter across renderers. + if (focusedActivityFilter !== null && focusedActivityID !== null) { + focusedActivityFilter.activityID = focusedActivityID; + } + flushPendingEvents(); needsToFlushComponentLogs = false; @@ -1621,6 +1686,10 @@ export function attach( data: ReactComponentInfo, secondaryEnv: null | string, ): boolean { + if (!isInFocusedActivity) { + return true; + } + // For purposes of filtering Server Components are always Function Components. // Environment will be used to filter Server vs Client. // Technically they can be forwardRef and memo too but those filters will go away @@ -1656,6 +1725,11 @@ export function attach( function shouldFilterFiber(fiber: Fiber): boolean { const {tag, type, key} = fiber; + // It is never valid to filter the root element. + if (tag !== HostRoot && !isInFocusedActivity) { + return true; + } + switch (tag) { case DehydratedSuspenseComponent: // TODO: ideally we would show dehydrated Suspense immediately. @@ -4020,11 +4094,23 @@ export function attach( fiber: Fiber, traceNearestHostComponentUpdate: boolean, ): void { + const isFocusedActivityEntry = + focusedActivity !== null && + (fiber === focusedActivity || fiber.alternate === focusedActivity); + if (isFocusedActivityEntry) { + isInFocusedActivity = true; + } + const shouldIncludeInTree = !shouldFilterFiber(fiber); let newInstance = null; let newSuspenseNode = null; if (shouldIncludeInTree) { newInstance = recordMount(fiber, reconcilingParent); + if (isFocusedActivityEntry) { + focusedActivityID = newInstance.id; + pushOperation(TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE); + pushOperation(newInstance.id); + } if (fiber.tag === SuspenseComponent || fiber.tag === HostRoot) { newSuspenseNode = createSuspenseNode(newInstance); // Measure this Suspense node. In general we shouldn't do this until we have @@ -4140,6 +4226,7 @@ export function attach( const stashedSuspenseParent = reconcilingParentSuspenseNode; const stashedSuspensePrevious = previouslyReconciledSiblingSuspenseNode; const stashedSuspenseRemaining = remainingReconcilingChildrenSuspenseNodes; + const stashedIsInActivitySlice = isInFocusedActivity; if (newInstance !== null) { // Push a new DevTools instance parent while reconciling this subtree. reconcilingParent = newInstance; @@ -4153,6 +4240,17 @@ export function attach( remainingReconcilingChildrenSuspenseNodes = null; shouldPopSuspenseNode = true; } + if ( + !isFocusedActivityEntry && + focusedActivity !== null && + fiber.tag === ActivityComponent + ) { + // We're not filtering how Activity within the focused activity. + // We cut of the bottom in the Frontend if we want to just show the + // Activity slice instead of all Activity descendants. + // The filtering in the backend only happens because filtering out + // everything above the focused Activity is hard to implement in the frontend. + } try { if (traceUpdatesEnabled) { if (traceNearestHostComponentUpdate) { @@ -4280,6 +4378,7 @@ export function attach( } } } finally { + isInFocusedActivity = stashedIsInActivitySlice; if (newInstance !== null) { reconcilingParent = stashedParent; previouslyReconciledSibling = stashedPrevious; @@ -4311,6 +4410,7 @@ export function attach( const stashedSuspenseParent = reconcilingParentSuspenseNode; const stashedSuspensePrevious = previouslyReconciledSiblingSuspenseNode; const stashedSuspenseRemaining = remainingReconcilingChildrenSuspenseNodes; + const stashedIsInActivitySlice = isInFocusedActivity; const previousSuspendedBy = instance.suspendedBy; // Push a new DevTools instance parent while reconciling this subtree. reconcilingParent = instance; @@ -4329,6 +4429,19 @@ export function attach( shouldPopSuspenseNode = true; } + if (focusedActivity !== null) { + if (instance.id === focusedActivityID) { + isInFocusedActivity = true; + } else if ( + instance.kind === FIBER_INSTANCE && + instance.data !== null && + instance.data.tag === ActivityComponent + ) { + // Filtering nested Activity components inside the focused activity + // is done in the frontend. + } + } + try { // Unmount the remaining set. if ( @@ -4379,6 +4492,7 @@ export function attach( previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; } + isInFocusedActivity = stashedIsInActivitySlice; } if (instance.kind === FIBER_INSTANCE) { recordUnmount(instance); @@ -5059,6 +5173,7 @@ export function attach( const stashedSuspenseParent = reconcilingParentSuspenseNode; const stashedSuspensePrevious = previouslyReconciledSiblingSuspenseNode; const stashedSuspenseRemaining = remainingReconcilingChildrenSuspenseNodes; + const stashedIsInActivitySlice = isInFocusedActivity; let updateFlags = NoUpdate; let shouldMeasureSuspenseNode = false; let shouldPopSuspenseNode = false; @@ -5098,6 +5213,15 @@ export function attach( shouldMeasureSuspenseNode = true; shouldPopSuspenseNode = true; } + + if (focusedActivity !== null) { + if (fiberInstance.id === focusedActivityID) { + isInFocusedActivity = true; + } else if (nextFiber.tag === ActivityComponent) { + // Filtering nested Activity components inside the focused activity + // is done in the frontend. + } + } } try { trackDebugInfoFromLazyType(nextFiber); @@ -5522,6 +5646,7 @@ export function attach( previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; } + isInFocusedActivity = stashedIsInActivitySlice; } } } diff --git a/packages/react-devtools-shared/src/constants.js b/packages/react-devtools-shared/src/constants.js index c398e130d841b..c965282e60d66 100644 --- a/packages/react-devtools-shared/src/constants.js +++ b/packages/react-devtools-shared/src/constants.js @@ -29,6 +29,7 @@ export const SUSPENSE_TREE_OPERATION_REMOVE = 9; export const SUSPENSE_TREE_OPERATION_REORDER_CHILDREN = 10; export const SUSPENSE_TREE_OPERATION_RESIZE = 11; export const SUSPENSE_TREE_OPERATION_SUSPENDERS = 12; +export const TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE = 13; export const PROFILING_FLAG_BASIC_SUPPORT /*. */ = 0b001; export const PROFILING_FLAG_TIMELINE_SUPPORT /* */ = 0b010; diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 5d84d12d1b894..420817198b734 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -21,13 +21,18 @@ import { TREE_OPERATION_SET_SUBTREE_MODE, TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS, TREE_OPERATION_UPDATE_TREE_BASE_DURATION, + TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE, SUSPENSE_TREE_OPERATION_ADD, SUSPENSE_TREE_OPERATION_REMOVE, SUSPENSE_TREE_OPERATION_REORDER_CHILDREN, SUSPENSE_TREE_OPERATION_RESIZE, SUSPENSE_TREE_OPERATION_SUSPENDERS, } from '../constants'; -import {ElementTypeRoot} from '../frontend/types'; +import { + ElementTypeRoot, + ElementTypeActivity, + ComponentFilterActivitySlice, +} from '../frontend/types'; import { getSavedComponentFilters, setSavedComponentFilters, @@ -144,7 +149,13 @@ export default class Store extends EventEmitter<{ hookSettings: [$ReadOnly], hostInstanceSelected: [Element['id']], settingsUpdated: [$ReadOnly], - mutated: [[Array, Map]], + mutated: [ + [ + Array, + Map, + Element['id'] | null, + ], + ], recordChangeDescriptions: [], roots: [], rootSupportsBasicProfiling: [], @@ -1156,7 +1167,7 @@ export default class Store extends EventEmitter<{ // The Tree context's search reducer expects an explicit list of ids for nodes that were added or removed. // In this case, we can pass it empty arrays since nodes in a collapsed tree are still there (just hidden). // Updating the selected search index later may require auto-expanding a collapsed subtree though. - this.emit('mutated', [[], new Map()]); + this.emit('mutated', [[], new Map(), null]); } } } @@ -1225,10 +1236,11 @@ export default class Store extends EventEmitter<{ const addedElementIDs: Array = []; // This is a mapping of removed ID -> parent ID: + // We'll use the parent ID to adjust selection if it gets deleted. const removedElementIDs: Map = new Map(); const removedSuspenseIDs: Map = new Map(); - // We'll use the parent ID to adjust selection if it gets deleted. + let nextActivitySliceID = null; let i = 2; @@ -1962,6 +1974,11 @@ export default class Store extends EventEmitter<{ break; } + case TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE: { + i++; + nextActivitySliceID = operations[i++]; + break; + } default: this._throwAndEmitError( new UnsupportedBridgeOperationError( @@ -2060,9 +2077,80 @@ export default class Store extends EventEmitter<{ console.groupEnd(); } - this.emit('mutated', [addedElementIDs, removedElementIDs]); + if (nextActivitySliceID !== null && nextActivitySliceID !== 0) { + let didCollapse = false; + // The backend filtered everything above the Activity slice. + // We need to hide everything below the Activity slice by collapsing + // the Activities that are descendants of the next Activity slice. + const nextActivitySlice = this._idToElement.get(nextActivitySliceID); + if (nextActivitySlice === undefined) { + throw new Error('Next Activity slice not found in Store.'); + } + + for (let j = 0; j < nextActivitySlice.children.length; j++) { + didCollapse ||= this._collapseActivitiesRecursively( + nextActivitySlice.children[j], + ); + } + + if (didCollapse) { + let weightAcrossRoots = 0; + this._roots.forEach(rootID => { + const {weight} = ((this.getElementByID(rootID): any): Element); + weightAcrossRoots += weight; + }); + this._weightAcrossRoots = weightAcrossRoots; + } + } + + for (let j = 0; j < this._componentFilters.length; j++) { + const filter = this._componentFilters[j]; + // If we're focusing an Activity, IDs may have changed. + if (filter.type === ComponentFilterActivitySlice) { + if (nextActivitySliceID === null || nextActivitySliceID === 0) { + filter.isValid = false; + } else { + filter.activityID = nextActivitySliceID; + } + } + } + + this.emit('mutated', [ + addedElementIDs, + removedElementIDs, + nextActivitySliceID, + ]); }; + _collapseActivitiesRecursively(elementID: number): boolean { + let didMutate = false; + const element = this._idToElement.get(elementID); + if (element === undefined) { + throw new Error('Element not found in Store.'); + } + + if (element.type === ElementTypeActivity) { + if (!element.isCollapsed) { + element.isCollapsed = true; + + const weightDelta = 1 - element.weight; + + let parentElement = this._idToElement.get(element.parentID); + while (parentElement !== undefined) { + parentElement.weight += weightDelta; + parentElement = this._idToElement.get(parentElement.parentID); + } + return true; + } + return false; + } + + for (let i = 0; i < element.children.length; i++) { + didMutate ||= this._collapseActivitiesRecursively(element.children[i]); + } + return didMutate; + } + // Certain backends save filters on a per-domain basis. // In order to prevent filter preferences and applied filters from being out of sync, // this message enables the backend to override the frontend's current ("saved") filters. @@ -2228,7 +2316,7 @@ export default class Store extends EventEmitter<{ if (previousStatus !== status) { // Propagate to subscribers, although tree state has not changed - this.emit('mutated', [[], new Map()]); + this.emit('mutated', [[], new Map(), null]); } } diff --git a/packages/react-devtools-shared/src/devtools/views/Components/ActivitySlice.css b/packages/react-devtools-shared/src/devtools/views/Components/ActivitySlice.css new file mode 100644 index 0000000000000..52ecc865e5cb4 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/ActivitySlice.css @@ -0,0 +1,28 @@ +.ActivitySlice { + max-width: 100%; + overflow-x: auto; + flex: 1; + display: flex; + align-items: center; + position: relative; +} + +.ActivitySliceButton { + color: var(--color-button-active); + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-normal); +} + +.Bar { + display: flex; + flex: 1 1 auto; + overflow-x: auto; +} + +.VRule { + flex: 0 0 auto; + height: 20px; + width: 1px; + background-color: var(--color-border); + margin: 0 0.5rem; +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/ActivitySlice.js b/packages/react-devtools-shared/src/devtools/views/Components/ActivitySlice.js new file mode 100644 index 0000000000000..707a5108c6c38 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/ActivitySlice.js @@ -0,0 +1,52 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ +import * as React from 'react'; +import {startTransition, useContext} from 'react'; +import Button from '../Button'; +import ButtonIcon from '../ButtonIcon'; +import {StoreContext} from '../context'; +import {useChangeActivitySliceAction} from '../SuspenseTab/ActivityList'; +import {TreeDispatcherContext, TreeStateContext} from './TreeContext'; +import styles from './ActivitySlice.css'; + +export default function ActivitySlice(): React.Node { + const dispatch = useContext(TreeDispatcherContext); + const {activityID} = useContext(TreeStateContext); + const store = useContext(StoreContext); + + const activity = + activityID === null ? null : store.getElementByID(activityID); + const name = activity ? activity.nameProp : null; + + const changeActivitySliceAction = useChangeActivitySliceAction(); + + return ( +
+
+ +
+
+ +
+ ); +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Element.js b/packages/react-devtools-shared/src/devtools/views/Components/Element.js index 24ce695cfcfcd..43e611fd1bd55 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Element.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Element.js @@ -8,8 +8,9 @@ */ import * as React from 'react'; -import {Fragment, useContext, useMemo, useState} from 'react'; +import {Fragment, startTransition, useContext, useMemo, useState} from 'react'; import Store from 'react-devtools-shared/src/devtools/store'; +import {ElementTypeActivity} from 'react-devtools-shared/src/frontend/types'; import ButtonIcon from '../ButtonIcon'; import {TreeDispatcherContext, TreeStateContext} from './TreeContext'; import {StoreContext} from '../context'; @@ -25,6 +26,7 @@ import styles from './Element.css'; import Icon from '../Icon'; import {useChangeOwnerAction} from './OwnersListContext'; import Tooltip from './reach-ui/tooltip'; +import {useChangeActivitySliceAction} from '../SuspenseTab/ActivityList'; type Props = { data: ItemData, @@ -65,6 +67,7 @@ export default function Element({data, index, style}: Props): React.Node { }>(errorsAndWarningsSubscription); const changeOwnerAction = useChangeOwnerAction(); + const changeActivitySliceAction = useChangeActivitySliceAction(); // Handle elements that are removed from the tree while an async render is in progress. if (element == null) { @@ -75,9 +78,13 @@ export default function Element({data, index, style}: Props): React.Node { } const handleDoubleClick = () => { - if (id !== null) { - changeOwnerAction(id); - } + startTransition(() => { + if (element.type === ElementTypeActivity) { + changeActivitySliceAction(element.id); + } else { + changeOwnerAction(element.id); + } + }); }; // $FlowFixMe[missing-local-annot] diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Tree.js b/packages/react-devtools-shared/src/devtools/views/Components/Tree.js index 8d763536e3020..5623d507a3b71 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Tree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Tree.js @@ -11,6 +11,7 @@ import * as React from 'react'; import { Fragment, Suspense, + startTransition, useCallback, useContext, useEffect, @@ -37,7 +38,10 @@ import ButtonIcon from '../ButtonIcon'; import Button from '../Button'; import {logEvent} from 'react-devtools-shared/src/Logger'; import {useExtensionComponentsPanelVisibility} from 'react-devtools-shared/src/frontend/hooks/useExtensionComponentsPanelVisibility'; +import {ElementTypeActivity} from 'react-devtools-shared/src/frontend/types'; import {useChangeOwnerAction} from './OwnersListContext'; +import {useChangeActivitySliceAction} from '../SuspenseTab/ActivityList'; +import ActivitySlice from './ActivitySlice'; // Indent for each node at level N, compared to node at level N - 1. const INDENTATION_SIZE = 10; @@ -72,6 +76,7 @@ function calculateInitialScrollOffset( export default function Tree(): React.Node { const dispatch = useContext(TreeDispatcherContext); const { + activityID, numElements, ownerID, searchIndex, @@ -302,6 +307,7 @@ export default function Tree(): React.Node { const handleBlur = useCallback(() => setTreeFocused(false), []); const handleFocus = useCallback(() => setTreeFocused(true), []); + const changeActivitySliceAction = useChangeActivitySliceAction(); const changeOwnerAction = useChangeOwnerAction(); const handleKeyPress = useCallback( (event: $FlowFixMe) => { @@ -309,7 +315,17 @@ export default function Tree(): React.Node { case 'Enter': case ' ': if (inspectedElementID !== null) { - changeOwnerAction(inspectedElementID); + const inspectedElement = store.getElementByID(inspectedElementID); + startTransition(() => { + if ( + inspectedElement !== null && + inspectedElement.type === ElementTypeActivity + ) { + changeActivitySliceAction(inspectedElementID); + } else { + changeOwnerAction(inspectedElementID); + } + }); } break; default: @@ -444,7 +460,13 @@ export default function Tree(): React.Node { )} }> - {ownerID !== null ? : } + {ownerID !== null ? ( + + ) : activityID !== null ? ( + + ) : ( + + )} {ownerID === null && (errors > 0 || warnings > 0) && ( diff --git a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js index 72556543f4b33..4ad28c38935d3 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js @@ -57,6 +57,9 @@ export type StateContext = { ownerID: number | null, ownerFlatTree: Array | null, + // Activity slice + activityID: Element['id'] | null, + // Inspection element panel inspectedElementID: number | null, inspectedElementIndex: number | null, @@ -70,7 +73,7 @@ type ACTION_GO_TO_PREVIOUS_SEARCH_RESULT = { }; type ACTION_HANDLE_STORE_MUTATION = { type: 'HANDLE_STORE_MUTATION', - payload: [Array, Map], + payload: [Array, Map, null | Element['id']], }; type ACTION_RESET_OWNER_STACK = { type: 'RESET_OWNER_STACK', @@ -167,6 +170,9 @@ type State = { ownerID: number | null, ownerFlatTree: Array | null, + // Activity slice + activityID: Element['id'] | null, + // Inspection element panel inspectedElementID: number | null, inspectedElementIndex: number | null, @@ -794,6 +800,33 @@ function reduceOwnersState(store: Store, state: State, action: Action): State { }; } +function reduceActivityState( + store: Store, + state: State, + action: Action, +): State { + switch (action.type) { + case 'HANDLE_STORE_MUTATION': + let {activityID} = state; + const [, , activitySliceIDChange] = action.payload; + if (activitySliceIDChange === 0 && activityID !== null) { + activityID = null; + } else if ( + activitySliceIDChange !== null && + activitySliceIDChange !== activityID + ) { + activityID = activitySliceIDChange; + } + if (activityID !== state.activityID) { + return { + ...state, + activityID, + }; + } + } + return state; +} + type Props = { children: React$Node, @@ -828,6 +861,9 @@ function getInitialState({ ownerID: defaultOwnerID == null ? null : defaultOwnerID, ownerFlatTree: null, + // Activity slice + activityID: null, + // Inspection element panel inspectedElementID: defaultInspectedElementID != null @@ -882,6 +918,7 @@ function TreeContextController({ state = reduceTreeState(store, state, action); state = reduceSearchState(store, state, action); state = reduceOwnersState(store, state, action); + state = reduceActivityState(store, state, action); // TODO(hoxyq): review // If the selected ID is in a collapsed subtree, reset the selected index to null. @@ -950,13 +987,14 @@ function TreeContextController({ // Mutations to the underlying tree may impact this context (e.g. search results, selection state). useEffect(() => { - const handleStoreMutated = ([addedElementIDs, removedElementIDs]: [ - Array, - Map, - ]) => { + const handleStoreMutated = ([ + addedElementIDs, + removedElementIDs, + activitySliceIDChange, + ]: [Array, Map, null | Element['id']]) => { dispatch({ type: 'HANDLE_STORE_MUTATION', - payload: [addedElementIDs, removedElementIDs], + payload: [addedElementIDs, removedElementIDs, activitySliceIDChange], }); }; @@ -967,7 +1005,7 @@ function TreeContextController({ // It would only impact the search state, which is unlikely to exist yet at this point. dispatch({ type: 'HANDLE_STORE_MUTATION', - payload: [[], new Map()], + payload: [[], new Map(), null], }); } diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js index d2226a183a140..41c5c7a2c0988 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js @@ -16,6 +16,7 @@ import { TREE_OPERATION_SET_SUBTREE_MODE, TREE_OPERATION_UPDATE_TREE_BASE_DURATION, TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS, + TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE, SUSPENSE_TREE_OPERATION_ADD, SUSPENSE_TREE_OPERATION_REMOVE, SUSPENSE_TREE_OPERATION_REORDER_CHILDREN, @@ -475,6 +476,20 @@ function updateTree( break; } + case TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE: { + i++; + const activitySliceIDChange = operations[i++]; + if (__DEBUG__) { + debug( + 'Applied activity slice change', + activitySliceIDChange === 0 + ? 'Reset applied activity slice' + : `Changed to activity slice ID ${activitySliceIDChange}`, + ); + } + break; + } + default: throw Error(`Unsupported Bridge operation "${operation}"`); } diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js b/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js index 33e98835bb1b3..9b73e949429b7 100644 --- a/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js +++ b/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js @@ -29,6 +29,7 @@ import { ComponentFilterHOC, ComponentFilterLocation, ComponentFilterEnvironmentName, + ComponentFilterActivitySlice, ElementTypeClass, ElementTypeContext, ElementTypeFunction, @@ -171,6 +172,8 @@ export default function ComponentsSettings({ isValid: true, value: 'Client', }; + } else if (type === ComponentFilterActivitySlice) { + // TODO: Allow changing type } } return cloned; @@ -364,34 +367,39 @@ export default function ComponentsSettings({ {componentFilters.map((componentFilter, index) => ( - - toggleFilterIsEnabled(componentFilter, isEnabled) - } - title={ - componentFilter.isValid === false - ? 'Filter invalid' - : componentFilter.isEnabled - ? 'Filter enabled' - : 'Filter disabled' - }> - - + isChecked={componentFilter.isEnabled} + onChange={isEnabled => + toggleFilterIsEnabled(componentFilter, isEnabled) + } + title={ + componentFilter.isValid === false + ? 'Filter invalid' + : componentFilter.isEnabled + ? 'Filter enabled' + : 'Filter disabled' + }> + + + )} @@ -422,6 +435,8 @@ export default function ComponentsSettings({ {(componentFilter.type === ComponentFilterLocation || componentFilter.type === ComponentFilterDisplayName) && 'matches'} + {componentFilter.type === ComponentFilterActivitySlice && + 'within'} {componentFilter.type === ComponentFilterElementType && ( @@ -487,6 +502,9 @@ export default function ComponentsSettings({ ))} )} + {componentFilter.type === ComponentFilterActivitySlice && ( + Activity Slice + )}