Compare commits

...

1 Commits

Author SHA1 Message Date
Mofei Zhang
b658948159 [DevTools] Add Filtering of Environment Names (#30850)
Stacked on #30842.

This adds a filter to be able to exclude Components from a certain
environment. Default to Client or Server.

The available options are computed into a dropdown based on the names
that are currently used on the page (or an option that were previously
used). In addition to the hardcoded "Client". Meaning that if you have
Server Components on the page you see "Server" or "Client" as possible
options but it can be anything if there are multiple RSC environments on
the page.

"Client" in this case means Function and Class Components in Fiber -
excluding built-ins.

If a Server Component has two environments (primary and secondary) then
both have to be filtered to exclude it.

We don't show the option at all if there are no Server Components used
in the page to avoid confusing existing users that are just using Client
Components and wouldn't know the difference between Server vs Client.

<img width="815" alt="Screenshot 2024-08-30 at 12 56 42 AM"
src="https://github.com/user-attachments/assets/e06b225a-e85d-4cdc-8707-d4630fede19e">

[ghstack-poisoned]
2024-10-11 16:21:29 -04:00
10 changed files with 253 additions and 26 deletions

View File

@@ -284,6 +284,19 @@ export function createHOCFilter(isEnabled: boolean = true) {
};
}
export function createEnvironmentNameFilter(
env: string,
isEnabled: boolean = true,
) {
const Types = require('react-devtools-shared/src/frontend/types');
return {
type: Types.ComponentFilterEnvironmentName,
isEnabled,
isValid: true,
value: env,
};
}
export function createElementTypeFilter(
elementType: ElementType,
isEnabled: boolean = true,

View File

@@ -220,6 +220,7 @@ export default class Agent extends EventEmitter<{
this.updateConsolePatchSettings,
);
bridge.addListener('updateComponentFilters', this.updateComponentFilters);
bridge.addListener('getEnvironmentNames', this.getEnvironmentNames);
// Temporarily support older standalone front-ends sending commands to newer embedded backends.
// We do this because React Native embeds the React DevTools backend,
@@ -814,6 +815,24 @@ export default class Agent extends EventEmitter<{
}
};
getEnvironmentNames: () => void = () => {
let accumulatedNames = null;
for (const rendererID in this._rendererInterfaces) {
const renderer = this._rendererInterfaces[+rendererID];
const names = renderer.getEnvironmentNames();
if (accumulatedNames === null) {
accumulatedNames = names;
} else {
for (let i = 0; i < names.length; i++) {
if (accumulatedNames.indexOf(names[i]) === -1) {
accumulatedNames.push(names[i]);
}
}
}
}
this._bridge.send('environmentNames', accumulatedNames || []);
};
onTraceUpdates: (nodes: Set<HostInstance>) => void = nodes => {
this.emit('traceUpdates', nodes);
};

View File

@@ -14,6 +14,7 @@ import {
ComponentFilterElementType,
ComponentFilterHOC,
ComponentFilterLocation,
ComponentFilterEnvironmentName,
ElementTypeClass,
ElementTypeContext,
ElementTypeFunction,
@@ -721,6 +722,11 @@ export function getInternalReactConstants(version: string): {
};
}
// All environment names we've seen so far. This lets us create a list of filters to apply.
// This should ideally include env of filtered Components too so that you can add those as
// filters at the same time as removing some other filter.
const knownEnvironmentNames: Set<string> = new Set();
// Map of one or more Fibers in a pair to their unique id number.
// We track both Fibers to support Fast Refresh,
// which may forcefully replace one of the pair as part of hot reloading.
@@ -1099,6 +1105,7 @@ export function attach(
const hideElementsWithDisplayNames: Set<RegExp> = new Set();
const hideElementsWithPaths: Set<RegExp> = new Set();
const hideElementsWithTypes: Set<ElementType> = new Set();
const hideElementsWithEnvs: Set<string> = new Set();
// Highlight updates
let traceUpdatesEnabled: boolean = false;
@@ -1108,6 +1115,7 @@ export function attach(
hideElementsWithTypes.clear();
hideElementsWithDisplayNames.clear();
hideElementsWithPaths.clear();
hideElementsWithEnvs.clear();
componentFilters.forEach(componentFilter => {
if (!componentFilter.isEnabled) {
@@ -1133,6 +1141,9 @@ export function attach(
case ComponentFilterHOC:
hideElementsWithDisplayNames.add(new RegExp('\\('));
break;
case ComponentFilterEnvironmentName:
hideElementsWithEnvs.add(componentFilter.value);
break;
default:
console.warn(
`Invalid component filter type "${componentFilter.type}"`,
@@ -1215,7 +1226,14 @@ export function attach(
flushPendingEvents();
}
function shouldFilterVirtual(data: ReactComponentInfo): boolean {
function getEnvironmentNames(): Array<string> {
return Array.from(knownEnvironmentNames);
}
function shouldFilterVirtual(
data: ReactComponentInfo,
secondaryEnv: null | string,
): boolean {
// 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
@@ -1236,6 +1254,14 @@ export function attach(
}
}
if (
(data.env == null || hideElementsWithEnvs.has(data.env)) &&
(secondaryEnv === null || hideElementsWithEnvs.has(secondaryEnv))
) {
// If a Component has two environments, you have to filter both for it not to appear.
return true;
}
return false;
}
@@ -1294,6 +1320,26 @@ export function attach(
}
}
if (hideElementsWithEnvs.has('Client')) {
// If we're filtering out the Client environment we should filter out all
// "Client Components". Technically that also includes the built-ins but
// since that doesn't actually include any additional code loading it's
// useful to not filter out the built-ins. Those can be filtered separately.
// There's no other way to filter out just Function components on the Client.
// Therefore, this only filters Class and Function components.
switch (tag) {
case ClassComponent:
case IncompleteClassComponent:
case IncompleteFunctionComponent:
case FunctionComponent:
case IndeterminateComponent:
case ForwardRef:
case MemoComponent:
case SimpleMemoComponent:
return true;
}
}
/* DISABLED: https://github.com/facebook/react/pull/28417
if (hideElementsWithPaths.size > 0) {
const source = getSourceForFiber(fiber);
@@ -2489,7 +2535,14 @@ export function attach(
}
// Scan up until the next Component to see if this component changed environment.
const componentInfo: ReactComponentInfo = (debugEntry: any);
if (shouldFilterVirtual(componentInfo)) {
const secondaryEnv = getSecondaryEnvironmentName(fiber._debugInfo, i);
if (componentInfo.env != null) {
knownEnvironmentNames.add(componentInfo.env);
}
if (secondaryEnv !== null) {
knownEnvironmentNames.add(secondaryEnv);
}
if (shouldFilterVirtual(componentInfo, secondaryEnv)) {
// Skip.
continue;
}
@@ -2511,10 +2564,6 @@ export function attach(
);
}
previousVirtualInstance = createVirtualInstance(componentInfo);
const secondaryEnv = getSecondaryEnvironmentName(
fiber._debugInfo,
i,
);
recordVirtualMount(
previousVirtualInstance,
reconcilingParent,
@@ -2919,7 +2968,17 @@ export function attach(
continue;
}
const componentInfo: ReactComponentInfo = (debugEntry: any);
if (shouldFilterVirtual(componentInfo)) {
const secondaryEnv = getSecondaryEnvironmentName(
nextChild._debugInfo,
i,
);
if (componentInfo.env != null) {
knownEnvironmentNames.add(componentInfo.env);
}
if (secondaryEnv !== null) {
knownEnvironmentNames.add(secondaryEnv);
}
if (shouldFilterVirtual(componentInfo, secondaryEnv)) {
continue;
}
if (level === virtualLevel) {
@@ -2983,10 +3042,6 @@ export function attach(
} else {
// Otherwise we create a new instance.
const newVirtualInstance = createVirtualInstance(componentInfo);
const secondaryEnv = getSecondaryEnvironmentName(
nextChild._debugInfo,
i,
);
recordVirtualMount(
newVirtualInstance,
reconcilingParent,
@@ -3925,7 +3980,7 @@ export function attach(
owner = ownerFiber._debugOwner;
} else {
const ownerInfo: ReactComponentInfo = (owner: any); // Refined
if (!shouldFilterVirtual(ownerInfo)) {
if (!shouldFilterVirtual(ownerInfo, null)) {
return ownerInfo;
}
owner = ownerInfo.owner;
@@ -5750,5 +5805,6 @@ export function attach(
storeAsGlobal,
unpatchConsoleForStrictMode,
updateComponentFilters,
getEnvironmentNames,
};
}

View File

@@ -1078,6 +1078,11 @@ export function attach(
// Not implemented.
}
function getEnvironmentNames(): Array<string> {
// No RSC support.
return [];
}
function setTraceUpdatesEnabled(enabled: boolean) {
// Not implemented.
}
@@ -1152,5 +1157,6 @@ export function attach(
storeAsGlobal,
unpatchConsoleForStrictMode,
updateComponentFilters,
getEnvironmentNames,
};
}

View File

@@ -416,6 +416,7 @@ export type RendererInterface = {
) => void,
unpatchConsoleForStrictMode: () => void,
updateComponentFilters: (componentFilters: Array<ComponentFilter>) => void,
getEnvironmentNames: () => Array<string>,
// Timeline profiler interface

View File

@@ -189,6 +189,7 @@ export type BackendEvents = {
operations: [Array<number>],
ownersList: [OwnersList],
overrideComponentFilters: [Array<ComponentFilter>],
environmentNames: [Array<string>],
profilingData: [ProfilingDataBackend],
profilingStatus: [boolean],
reloadAppForProfiling: [],
@@ -237,6 +238,7 @@ type FrontendEvents = {
stopProfiling: [],
storeAsGlobal: [StoreAsGlobalParams],
updateComponentFilters: [Array<ComponentFilter>],
getEnvironmentNames: [],
updateConsolePatchSettings: [ConsolePatchSettings],
viewAttributeSource: [ViewAttributeSourceParams],
viewElementSource: [ElementAndRendererID],

View File

@@ -15,6 +15,7 @@ import {
useMemo,
useRef,
useState,
use,
} from 'react';
import {
LOCAL_STORAGE_OPEN_IN_EDITOR_URL,
@@ -31,6 +32,7 @@ import {
ComponentFilterElementType,
ComponentFilterHOC,
ComponentFilterLocation,
ComponentFilterEnvironmentName,
ElementTypeClass,
ElementTypeContext,
ElementTypeFunction,
@@ -52,11 +54,16 @@ import type {
ElementType,
ElementTypeComponentFilter,
RegExpComponentFilter,
EnvironmentNameComponentFilter,
} from 'react-devtools-shared/src/frontend/types';
const vscodeFilepath = 'vscode://file/{path}:{line}';
export default function ComponentsSettings(_: {}): React.Node {
export default function ComponentsSettings({
environmentNames,
}: {
environmentNames: Promise<Array<string>>,
}): React.Node {
const store = useContext(StoreContext);
const {parseHookNames, setParseHookNames} = useContext(SettingsContext);
@@ -101,6 +108,30 @@ export default function ComponentsSettings(_: {}): React.Node {
Array<ComponentFilter>,
>(() => [...store.componentFilters]);
const usedEnvironmentNames = use(environmentNames);
const resolvedEnvironmentNames = useMemo(() => {
const set = new Set(usedEnvironmentNames);
// If there are other filters already specified but are not currently
// on the page, we still allow them as options.
for (let i = 0; i < componentFilters.length; i++) {
const filter = componentFilters[i];
if (filter.type === ComponentFilterEnvironmentName) {
set.add(filter.value);
}
}
// Client is special and is always available as a default.
if (set.size > 0) {
// Only show any options at all if there's any other option already
// used by a filter or if any environments are used by the page.
// Note that "Client" can have been added above which would mean
// that we should show it as an option regardless if it's the only
// option.
set.add('Client');
}
return Array.from(set).sort();
}, [usedEnvironmentNames, componentFilters]);
const addFilter = useCallback(() => {
setComponentFilters(prevComponentFilters => {
return [
@@ -146,6 +177,13 @@ export default function ComponentsSettings(_: {}): React.Node {
isEnabled: componentFilter.isEnabled,
isValid: true,
};
} else if (type === ComponentFilterEnvironmentName) {
cloned[index] = {
type: ComponentFilterEnvironmentName,
isEnabled: componentFilter.isEnabled,
isValid: true,
value: 'Client',
};
}
}
return cloned;
@@ -210,6 +248,29 @@ export default function ComponentsSettings(_: {}): React.Node {
[],
);
const updateFilterValueEnvironmentName = useCallback(
(componentFilter: ComponentFilter, value: string) => {
if (componentFilter.type !== ComponentFilterEnvironmentName) {
throw Error('Invalid value for environment name filter');
}
setComponentFilters(prevComponentFilters => {
const cloned: Array<ComponentFilter> = [...prevComponentFilters];
if (componentFilter.type === ComponentFilterEnvironmentName) {
const index = prevComponentFilters.indexOf(componentFilter);
if (index >= 0) {
cloned[index] = {
...componentFilter,
value,
};
}
}
return cloned;
});
},
[],
);
const removeFilter = useCallback((index: number) => {
setComponentFilters(prevComponentFilters => {
const cloned: Array<ComponentFilter> = [...prevComponentFilters];
@@ -246,6 +307,11 @@ export default function ComponentsSettings(_: {}): React.Node {
...((cloned[index]: any): BooleanComponentFilter),
isEnabled,
};
} else if (componentFilter.type === ComponentFilterEnvironmentName) {
cloned[index] = {
...((cloned[index]: any): EnvironmentNameComponentFilter),
isEnabled,
};
}
}
return cloned;
@@ -380,10 +446,16 @@ export default function ComponentsSettings(_: {}): React.Node {
<option value={ComponentFilterDisplayName}>name</option>
<option value={ComponentFilterElementType}>type</option>
<option value={ComponentFilterHOC}>hoc</option>
{resolvedEnvironmentNames.length > 0 && (
<option value={ComponentFilterEnvironmentName}>
environment
</option>
)}
</select>
</td>
<td className={styles.TableCell}>
{componentFilter.type === ComponentFilterElementType &&
{(componentFilter.type === ComponentFilterElementType ||
componentFilter.type === ComponentFilterEnvironmentName) &&
'equals'}
{(componentFilter.type === ComponentFilterLocation ||
componentFilter.type === ComponentFilterDisplayName) &&
@@ -428,6 +500,23 @@ export default function ComponentsSettings(_: {}): React.Node {
value={componentFilter.value}
/>
)}
{componentFilter.type === ComponentFilterEnvironmentName && (
<select
className={styles.Select}
value={componentFilter.value}
onChange={({currentTarget}) =>
updateFilterValueEnvironmentName(
componentFilter,
currentTarget.value,
)
}>
{resolvedEnvironmentNames.map(name => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
)}
</td>
<td className={styles.TableCell}>
<Button

View File

@@ -58,7 +58,8 @@ export default function SettingsModal(_: {}): React.Node {
}
function SettingsModalImpl(_: {}) {
const {setIsModalShowing} = useContext(SettingsModalContext);
const {setIsModalShowing, environmentNames} =
useContext(SettingsModalContext);
const dismissModal = useCallback(
() => setIsModalShowing(false),
[setIsModalShowing],
@@ -81,7 +82,7 @@ function SettingsModalImpl(_: {}) {
let view = null;
switch (selectedTabID) {
case 'components':
view = <ComponentsSettings />;
view = <ComponentsSettings environmentNames={environmentNames} />;
break;
// $FlowFixMe[incompatible-type] is this missing in TabID?
case 'debugging':

View File

@@ -10,7 +10,16 @@
import type {ReactContext} from 'shared/ReactTypes';
import * as React from 'react';
import {createContext, useMemo, useState} from 'react';
import {
createContext,
useContext,
useCallback,
useState,
startTransition,
} from 'react';
import {BridgeContext} from '../context';
import type {FrontendBridge} from '../../../bridge';
export type DisplayDensity = 'comfortable' | 'compact';
export type Theme = 'auto' | 'light' | 'dark';
@@ -18,7 +27,7 @@ export type Theme = 'auto' | 'light' | 'dark';
type Context = {
isModalShowing: boolean,
setIsModalShowing: (value: boolean) => void,
...
environmentNames: null | Promise<Array<string>>,
};
const SettingsModalContext: ReactContext<Context> = createContext<Context>(
@@ -26,20 +35,42 @@ const SettingsModalContext: ReactContext<Context> = createContext<Context>(
);
SettingsModalContext.displayName = 'SettingsModalContext';
function fetchEnvironmentNames(bridge: FrontendBridge): Promise<Array<string>> {
return new Promise(resolve => {
function onEnvironmentNames(names: Array<string>) {
bridge.removeListener('environmentNames', onEnvironmentNames);
resolve(names);
}
bridge.addListener('environmentNames', onEnvironmentNames);
bridge.send('getEnvironmentNames');
});
}
function SettingsModalContextController({
children,
}: {
children: React$Node,
}): React.Node {
const [isModalShowing, setIsModalShowing] = useState<boolean>(false);
const bridge = useContext(BridgeContext);
const value = useMemo(
() => ({isModalShowing, setIsModalShowing}),
[isModalShowing, setIsModalShowing],
);
const setIsModalShowing: boolean => void = useCallback((value: boolean) => {
startTransition(() => {
setContext({
isModalShowing: value,
setIsModalShowing,
environmentNames: value ? fetchEnvironmentNames(bridge) : null,
});
});
});
const [currentContext, setContext] = useState<Context>({
isModalShowing: false,
setIsModalShowing,
environmentNames: null,
});
return (
<SettingsModalContext.Provider value={value}>
<SettingsModalContext.Provider value={currentContext}>
{children}
</SettingsModalContext.Provider>
);

View File

@@ -76,8 +76,9 @@ export const ComponentFilterElementType = 1;
export const ComponentFilterDisplayName = 2;
export const ComponentFilterLocation = 3;
export const ComponentFilterHOC = 4;
export const ComponentFilterEnvironmentName = 5;
export type ComponentFilterType = 1 | 2 | 3 | 4;
export type ComponentFilterType = 1 | 2 | 3 | 4 | 5;
// Hide all elements of types in this Set.
// We hide host components only by default.
@@ -102,10 +103,18 @@ export type BooleanComponentFilter = {
type: 4,
};
export type EnvironmentNameComponentFilter = {
isEnabled: boolean,
isValid: boolean,
type: 5,
value: string,
};
export type ComponentFilter =
| BooleanComponentFilter
| ElementTypeComponentFilter
| RegExpComponentFilter;
| RegExpComponentFilter
| EnvironmentNameComponentFilter;
export type HookName = string | null;
// Map of hook source ("<filename>:<line-number>:<column-number>") to name.