Files
react/packages/react-devtools-shared/src/__tests__/utils.js
Sebastian Markbåge 04ec50efa9 [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">
2024-09-03 12:29:15 -04:00

496 lines
13 KiB
JavaScript

/**
* 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 semver from 'semver';
import typeof ReactTestRenderer from 'react-test-renderer';
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
import type Store from 'react-devtools-shared/src/devtools/store';
import type {ProfilingDataFrontend} from 'react-devtools-shared/src/devtools/views/Profiler/types';
import type {ElementType} from 'react-devtools-shared/src/frontend/types';
import type {Node as ReactNode} from 'react';
import {ReactVersion} from '../../../../ReactVersions';
const requestedReactVersion = process.env.REACT_VERSION || ReactVersion;
export function getActDOMImplementation(): () => void | Promise<void> {
// This is for React < 17, where act wasn't shipped yet.
if (semver.lt(requestedReactVersion, '17.0.0')) {
require('react-dom/test-utils');
return cb => cb();
}
// This is for React < 18, where act was distributed in react-dom/test-utils.
if (semver.lt(requestedReactVersion, '18.0.0')) {
const ReactDOMTestUtils = require('react-dom/test-utils');
return ReactDOMTestUtils.act;
}
const React = require('react');
// This is for React 18, where act was distributed in react as unstable.
if (React.unstable_act) {
return React.unstable_act;
}
// This is for React > 18, where act is marked as stable.
if (React.act) {
return React.act;
}
throw new Error("Couldn't find any available act implementation");
}
export function getActTestRendererImplementation(): () => void | Promise<void> {
// This is for React < 17, where act wasn't shipped yet.
if (semver.lt(requestedReactVersion, '17.0.0')) {
require('react-test-renderer');
return cb => cb();
}
const RTR = require('react-test-renderer');
if (RTR.act) {
return RTR.act;
}
throw new Error(
"Couldn't find any available act implementation in react-test-renderer",
);
}
export function act(
callback: Function,
recursivelyFlush: boolean = true,
): void {
// act from react-test-renderer has some side effects on React DevTools
// it injects the renderer for DevTools, see ReactTestRenderer.js
const actTestRenderer = getActTestRendererImplementation();
const actDOM = getActDOMImplementation();
actDOM(() => {
actTestRenderer(() => {
callback();
});
});
if (recursivelyFlush) {
// Flush Bridge operations
while (jest.getTimerCount() > 0) {
actDOM(() => {
actTestRenderer(() => {
jest.runAllTimers();
});
});
}
}
}
export async function actAsync(
cb: () => *,
recursivelyFlush: boolean = true,
): Promise<void> {
// act from react-test-renderer has some side effects on React DevTools
// it injects the renderer for DevTools, see ReactTestRenderer.js
const actTestRenderer = getActTestRendererImplementation();
const actDOM = getActDOMImplementation();
await actDOM(async () => {
await actTestRenderer(async () => {
await cb();
});
});
if (recursivelyFlush) {
while (jest.getTimerCount() > 0) {
await actDOM(async () => {
await actTestRenderer(async () => {
jest.runAllTimers();
});
});
}
} else {
await actDOM(async () => {
await actTestRenderer(async () => {
jest.runOnlyPendingTimers();
});
});
}
}
type RenderImplementation = {
render: (elements: ?ReactNode) => () => void,
unmount: () => void,
createContainer: () => void,
getContainer: () => ?HTMLElement,
};
export function getLegacyRenderImplementation(): RenderImplementation {
let ReactDOM;
let container;
const containersToRemove = [];
beforeEach(() => {
ReactDOM = require('react-dom');
createContainer();
});
afterEach(() => {
containersToRemove.forEach(c => document.body.removeChild(c));
containersToRemove.splice(0, containersToRemove.length);
ReactDOM = null;
container = null;
});
function render(elements) {
withErrorsOrWarningsIgnored(
['ReactDOM.render has not been supported since React 18'],
() => {
ReactDOM.render(elements, container);
},
);
return unmount;
}
function unmount() {
ReactDOM.unmountComponentAtNode(container);
}
function createContainer() {
container = document.createElement('div');
document.body.appendChild(container);
containersToRemove.push(container);
}
function getContainer() {
return container;
}
return {
render,
unmount,
createContainer,
getContainer,
};
}
export function getModernRenderImplementation(): RenderImplementation {
let ReactDOMClient;
let container;
let root;
const containersToRemove = [];
beforeEach(() => {
ReactDOMClient = require('react-dom/client');
createContainer();
});
afterEach(() => {
containersToRemove.forEach(c => document.body.removeChild(c));
containersToRemove.splice(0, containersToRemove.length);
ReactDOMClient = null;
container = null;
root = null;
});
function render(elements) {
if (root == null) {
root = ReactDOMClient.createRoot(container);
}
root.render(elements);
return unmount;
}
function unmount() {
root.unmount();
}
function createContainer() {
container = document.createElement('div');
document.body.appendChild(container);
root = null;
containersToRemove.push(container);
}
function getContainer() {
return container;
}
return {
render,
unmount,
createContainer,
getContainer,
};
}
export const getVersionedRenderImplementation: () => RenderImplementation =
semver.lt(requestedReactVersion, '18.0.0')
? getLegacyRenderImplementation
: getModernRenderImplementation;
export function beforeEachProfiling(): void {
// Mock React's timing information so that test runs are predictable.
jest.mock('scheduler', () => jest.requireActual('scheduler/unstable_mock'));
// DevTools itself uses performance.now() to offset commit times
// so they appear relative to when profiling was started in the UI.
jest
.spyOn(performance, 'now')
.mockImplementation(
jest.requireActual('scheduler/unstable_mock').unstable_now,
);
}
export function createDisplayNameFilter(
source: string,
isEnabled: boolean = true,
) {
const Types = require('react-devtools-shared/src/frontend/types');
let isValid = true;
try {
new RegExp(source); // eslint-disable-line no-new
} catch (error) {
isValid = false;
}
return {
type: Types.ComponentFilterDisplayName,
isEnabled,
isValid,
value: source,
};
}
export function createHOCFilter(isEnabled: boolean = true) {
const Types = require('react-devtools-shared/src/frontend/types');
return {
type: Types.ComponentFilterHOC,
isEnabled,
isValid: 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,
) {
const Types = require('react-devtools-shared/src/frontend/types');
return {
type: Types.ComponentFilterElementType,
isEnabled,
value: elementType,
};
}
export function createLocationFilter(
source: string,
isEnabled: boolean = true,
) {
const Types = require('react-devtools-shared/src/frontend/types');
let isValid = true;
try {
new RegExp(source); // eslint-disable-line no-new
} catch (error) {
isValid = false;
}
return {
type: Types.ComponentFilterLocation,
isEnabled,
isValid,
value: source,
};
}
export function getRendererID(): number {
if (global.agent == null) {
throw Error('Agent unavailable.');
}
const ids = Object.keys(global.agent._rendererInterfaces);
const id = ids.find(innerID => {
const rendererInterface = global.agent._rendererInterfaces[innerID];
return rendererInterface.renderer.rendererPackageName === 'react-dom';
});
if (id == null) {
throw Error('Could not find renderer.');
}
return parseInt(id, 10);
}
export function legacyRender(elements, container) {
if (container == null) {
container = document.createElement('div');
}
const ReactDOM = require('react-dom');
withErrorsOrWarningsIgnored(
['ReactDOM.render has not been supported since React 18'],
() => {
ReactDOM.render(elements, container);
},
);
return () => {
ReactDOM.unmountComponentAtNode(container);
};
}
export function requireTestRenderer(): ReactTestRenderer {
let hook;
try {
// Hide the hook before requiring TestRenderer, so we don't end up with a loop.
hook = global.__REACT_DEVTOOLS_GLOBAL_HOOK__;
delete global.__REACT_DEVTOOLS_GLOBAL_HOOK__;
return require('react-test-renderer');
} finally {
global.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
}
}
export function exportImportHelper(bridge: FrontendBridge, store: Store): void {
const {
prepareProfilingDataExport,
prepareProfilingDataFrontendFromExport,
} = require('react-devtools-shared/src/devtools/views/Profiler/utils');
const {profilerStore} = store;
expect(profilerStore.profilingData).not.toBeNull();
const profilingDataFrontendInitial =
((profilerStore.profilingData: any): ProfilingDataFrontend);
expect(profilingDataFrontendInitial.imported).toBe(false);
const profilingDataExport = prepareProfilingDataExport(
profilingDataFrontendInitial,
);
// Simulate writing/reading to disk.
const serializedProfilingDataExport = JSON.stringify(
profilingDataExport,
null,
2,
);
const parsedProfilingDataExport = JSON.parse(serializedProfilingDataExport);
const profilingDataFrontend = prepareProfilingDataFrontendFromExport(
(parsedProfilingDataExport: any),
);
expect(profilingDataFrontend.imported).toBe(true);
// Sanity check that profiling snapshots are serialized correctly.
expect(profilingDataFrontendInitial.dataForRoots).toEqual(
profilingDataFrontend.dataForRoots,
);
expect(profilingDataFrontendInitial.timelineData).toEqual(
profilingDataFrontend.timelineData,
);
// Snapshot the JSON-parsed object, rather than the raw string, because Jest formats the diff nicer.
// expect(parsedProfilingDataExport).toMatchSnapshot('imported data');
act(() => {
// Apply the new exported-then-imported data so tests can re-run assertions.
profilerStore.profilingData = profilingDataFrontend;
});
}
/**
* Runs `fn` while preventing console error and warnings that partially match any given `errorOrWarningMessages` from appearing in the console.
* @param errorOrWarningMessages Messages are matched partially (i.e. indexOf), pre-formatting.
* @param fn
*/
export function withErrorsOrWarningsIgnored<T: void | Promise<void>>(
errorOrWarningMessages: string[],
fn: () => T,
): T {
// withErrorsOrWarningsIgnored() may be nested.
const prev = global._ignoredErrorOrWarningMessages || [];
let resetIgnoredErrorOrWarningMessages = true;
try {
global._ignoredErrorOrWarningMessages = [
...prev,
...errorOrWarningMessages,
];
const maybeThenable = fn();
if (
maybeThenable !== undefined &&
typeof maybeThenable.then === 'function'
) {
resetIgnoredErrorOrWarningMessages = false;
return maybeThenable.then(
() => {
global._ignoredErrorOrWarningMessages = prev;
},
() => {
global._ignoredErrorOrWarningMessages = prev;
},
);
}
} finally {
if (resetIgnoredErrorOrWarningMessages) {
global._ignoredErrorOrWarningMessages = prev;
}
}
}
export function overrideFeatureFlags(overrideFlags) {
jest.mock('react-devtools-feature-flags', () => {
const actualFlags = jest.requireActual('react-devtools-feature-flags');
return {
...actualFlags,
...overrideFlags,
};
});
}
export function normalizeCodeLocInfo(str) {
if (typeof str === 'object' && str !== null) {
str = str.stack;
}
if (typeof str !== 'string') {
return str;
}
// This special case exists only for the special source location in
// ReactElementValidator. That will go away if we remove source locations.
str = str.replace(/Check your code at .+?:\d+/g, 'Check your code at **');
// V8 format:
// at Component (/path/filename.js:123:45)
// React format:
// in Component (at filename.js:123)
return str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function (m, name) {
return '\n in ' + name + ' (at **)';
});
}