main
js 505 lines 12.8 KB
Raw
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 semver from 'semver';
11
12 import typeof ReactTestRenderer from 'react-test-renderer';
13
14 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
15 import type Store from 'react-devtools-shared/src/devtools/store';
16 import type {ProfilingDataFrontend} from 'react-devtools-shared/src/devtools/views/Profiler/types';
17 import type {ElementType} from 'react-devtools-shared/src/frontend/types';
18 import type {Node as ReactNode} from 'react';
19
20 import {ReactVersion} from '../../../../ReactVersions';
21
22 const requestedReactVersion = process.env.REACT_VERSION || ReactVersion;
23 export function getActDOMImplementation(): () => void | Promise<void> {
24 // This is for React < 17, where act wasn't shipped yet.
25 if (semver.lt(requestedReactVersion, '17.0.0')) {
26 require('react-dom/test-utils');
27 return cb => cb();
28 }
29
30 // This is for React < 18, where act was distributed in react-dom/test-utils.
31 if (semver.lt(requestedReactVersion, '18.0.0')) {
32 const ReactDOMTestUtils = require('react-dom/test-utils');
33 return ReactDOMTestUtils.act;
34 }
35
36 const React = require('react');
37 // This is for React 18, where act was distributed in react as unstable.
38 if (React.unstable_act) {
39 return React.unstable_act;
40 }
41
42 // This is for React > 18, where act is marked as stable.
43 if (React.act) {
44 return React.act;
45 }
46
47 throw new Error("Couldn't find any available act implementation");
48 }
49
50 export function getActTestRendererImplementation(): () => void | Promise<void> {
51 // This is for React < 17, where act wasn't shipped yet.
52 if (semver.lt(requestedReactVersion, '17.0.0')) {
53 require('react-test-renderer');
54 return cb => cb();
55 }
56
57 const RTR = require('react-test-renderer');
58 if (RTR.act) {
59 return RTR.act;
60 }
61
62 throw new Error(
63 "Couldn't find any available act implementation in react-test-renderer",
64 );
65 }
66
67 export function act(
68 callback: Function,
69 recursivelyFlush: boolean = true,
70 ): void {
71 // act from react-test-renderer has some side effects on React DevTools
72 // it injects the renderer for DevTools, see ReactTestRenderer.js
73 const actTestRenderer = getActTestRendererImplementation();
74 const actDOM = getActDOMImplementation();
75
76 actDOM(() => {
77 actTestRenderer(() => {
78 callback();
79 });
80 });
81
82 if (recursivelyFlush) {
83 // Flush Bridge operations
84 while (jest.getTimerCount() > 0) {
85 actDOM(() => {
86 actTestRenderer(() => {
87 jest.runAllTimers();
88 });
89 });
90 }
91 }
92 }
93
94 export async function actAsync(
95 cb: () => *,
96 recursivelyFlush: boolean = true,
97 ): Promise<void> {
98 // act from react-test-renderer has some side effects on React DevTools
99 // it injects the renderer for DevTools, see ReactTestRenderer.js
100 const actTestRenderer = getActTestRendererImplementation();
101 const actDOM = getActDOMImplementation();
102
103 await actDOM(async () => {
104 await actTestRenderer(async () => {
105 await cb();
106 });
107 });
108
109 if (recursivelyFlush) {
110 while (jest.getTimerCount() > 0) {
111 await actDOM(async () => {
112 await actTestRenderer(async () => {
113 jest.runAllTimers();
114 });
115 });
116 }
117 } else {
118 await actDOM(async () => {
119 await actTestRenderer(async () => {
120 jest.runOnlyPendingTimers();
121 });
122 });
123 }
124 }
125
126 type RenderImplementation = {
127 render: (elements: ?ReactNode) => () => void,
128 unmount: () => void,
129 createContainer: () => void,
130 getContainer: () => ?HTMLElement,
131 };
132
133 export function getLegacyRenderImplementation(): RenderImplementation {
134 let ReactDOM;
135 let container;
136 const containersToRemove = [];
137
138 beforeEach(() => {
139 ReactDOM = require('react-dom');
140
141 createContainer();
142 });
143
144 afterEach(() => {
145 containersToRemove.forEach(c => document.body.removeChild(c));
146 containersToRemove.splice(0, containersToRemove.length);
147
148 ReactDOM = null;
149 container = null;
150 });
151
152 function render(elements) {
153 withErrorsOrWarningsIgnored(
154 ['ReactDOM.render has not been supported since React 18'],
155 () => {
156 ReactDOM.render(elements, container);
157 },
158 );
159
160 return unmount;
161 }
162
163 function unmount() {
164 ReactDOM.unmountComponentAtNode(container);
165 }
166
167 function createContainer() {
168 container = document.createElement('div');
169 document.body.appendChild(container);
170
171 containersToRemove.push(container);
172 }
173
174 function getContainer() {
175 return container;
176 }
177
178 return {
179 render,
180 unmount,
181 createContainer,
182 getContainer,
183 };
184 }
185
186 export function getModernRenderImplementation(): RenderImplementation {
187 let ReactDOMClient;
188 let container;
189 let root;
190 const containersToRemove = [];
191
192 beforeEach(() => {
193 ReactDOMClient = require('react-dom/client');
194
195 createContainer();
196 });
197
198 afterEach(() => {
199 containersToRemove.forEach(c => document.body.removeChild(c));
200 containersToRemove.splice(0, containersToRemove.length);
201
202 ReactDOMClient = null;
203 container = null;
204 root = null;
205 });
206
207 function render(elements) {
208 if (root == null) {
209 root = ReactDOMClient.createRoot(container);
210 }
211 root.render(elements);
212
213 return unmount;
214 }
215
216 function unmount() {
217 root.unmount();
218 }
219
220 function createContainer() {
221 container = document.createElement('div');
222 document.body.appendChild(container);
223
224 root = null;
225
226 containersToRemove.push(container);
227 }
228
229 function getContainer() {
230 return container;
231 }
232
233 return {
234 render,
235 unmount,
236 createContainer,
237 getContainer,
238 };
239 }
240
241 export const getVersionedRenderImplementation: () => RenderImplementation =
242 semver.lt(requestedReactVersion, '18.0.0')
243 ? getLegacyRenderImplementation
244 : getModernRenderImplementation;
245
246 export function beforeEachProfiling(): void {
247 // Mock React's timing information so that test runs are predictable.
248 jest.mock('scheduler', () => jest.requireActual('scheduler/unstable_mock'));
249
250 // DevTools itself uses performance.now() to offset commit times
251 // so they appear relative to when profiling was started in the UI.
252 jest
253 .spyOn(performance, 'now')
254 .mockImplementation(
255 jest.requireActual('scheduler/unstable_mock').unstable_now,
256 );
257 }
258
259 export function createDisplayNameFilter(
260 source: string,
261 isEnabled: boolean = true,
262 ) {
263 const Types = require('react-devtools-shared/src/frontend/types');
264 let isValid = true;
265 try {
266 new RegExp(source); // eslint-disable-line no-new
267 } catch (error) {
268 isValid = false;
269 }
270 return {
271 type: Types.ComponentFilterDisplayName,
272 isEnabled,
273 isValid,
274 value: source,
275 };
276 }
277
278 export function createHOCFilter(isEnabled: boolean = true) {
279 const Types = require('react-devtools-shared/src/frontend/types');
280 return {
281 type: Types.ComponentFilterHOC,
282 isEnabled,
283 isValid: true,
284 };
285 }
286
287 export function createEnvironmentNameFilter(
288 env: string,
289 isEnabled: boolean = true,
290 ) {
291 const Types = require('react-devtools-shared/src/frontend/types');
292 return {
293 type: Types.ComponentFilterEnvironmentName,
294 isEnabled,
295 isValid: true,
296 value: env,
297 };
298 }
299
300 export function createElementTypeFilter(
301 elementType: ElementType,
302 isEnabled: boolean = true,
303 ) {
304 const Types = require('react-devtools-shared/src/frontend/types');
305 return {
306 type: Types.ComponentFilterElementType,
307 isEnabled,
308 value: elementType,
309 };
310 }
311
312 export function createLocationFilter(
313 source: string,
314 isEnabled: boolean = true,
315 ) {
316 const Types = require('react-devtools-shared/src/frontend/types');
317 let isValid = true;
318 try {
319 new RegExp(source); // eslint-disable-line no-new
320 } catch (error) {
321 isValid = false;
322 }
323 return {
324 type: Types.ComponentFilterLocation,
325 isEnabled,
326 isValid,
327 value: source,
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.');
347 }
348 const ids = Object.keys(global.agent._rendererInterfaces);
349
350 const id = ids.find(innerID => {
351 const rendererInterface = global.agent._rendererInterfaces[innerID];
352 return rendererInterface.renderer.rendererPackageName === 'react-dom';
353 });
354
355 if (id == null) {
356 throw Error('Could not find renderer.');
357 }
358
359 return parseInt(id, 10);
360 }
361
362 export function legacyRender(elements, container) {
363 if (container == null) {
364 container = document.createElement('div');
365 }
366
367 const ReactDOM = require('react-dom');
368 withErrorsOrWarningsIgnored(
369 ['ReactDOM.render has not been supported since React 18'],
370 () => {
371 ReactDOM.render(elements, container);
372 },
373 );
374
375 return () => {
376 ReactDOM.unmountComponentAtNode(container);
377 };
378 }
379
380 export function requireTestRenderer(): ReactTestRenderer {
381 let hook;
382 try {
383 // Hide the hook before requiring TestRenderer, so we don't end up with a loop.
384 hook = global.__REACT_DEVTOOLS_GLOBAL_HOOK__;
385 delete global.__REACT_DEVTOOLS_GLOBAL_HOOK__;
386
387 return require('react-test-renderer');
388 } finally {
389 global.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
390 }
391 }
392
393 export function exportImportHelper(bridge: FrontendBridge, store: Store): void {
394 const {
395 prepareProfilingDataExport,
396 prepareProfilingDataFrontendFromExport,
397 } = require('react-devtools-shared/src/devtools/views/Profiler/utils');
398
399 const {profilerStore} = store;
400
401 expect(profilerStore.profilingData).not.toBeNull();
402
403 const profilingDataFrontendInitial =
404 ((profilerStore.profilingData: any): ProfilingDataFrontend);
405 expect(profilingDataFrontendInitial.imported).toBe(false);
406
407 const profilingDataExport = prepareProfilingDataExport(
408 profilingDataFrontendInitial,
409 );
410
411 // Simulate writing/reading to disk.
412 const serializedProfilingDataExport = JSON.stringify(
413 profilingDataExport,
414 null,
415 2,
416 );
417 const parsedProfilingDataExport = JSON.parse(serializedProfilingDataExport);
418
419 const profilingDataFrontend = prepareProfilingDataFrontendFromExport(
420 (parsedProfilingDataExport: any),
421 );
422 expect(profilingDataFrontend.imported).toBe(true);
423
424 // Sanity check that profiling snapshots are serialized correctly.
425 expect(profilingDataFrontendInitial.dataForRoots).toEqual(
426 profilingDataFrontend.dataForRoots,
427 );
428
429 // Snapshot the JSON-parsed object, rather than the raw string, because Jest formats the diff nicer.
430 // expect(parsedProfilingDataExport).toMatchSnapshot('imported data');
431
432 act(() => {
433 // Apply the new exported-then-imported data so tests can re-run assertions.
434 profilerStore.profilingData = profilingDataFrontend;
435 });
436 }
437
438 /**
439 * Runs `fn` while preventing console error and warnings that partially match any given `errorOrWarningMessages` from appearing in the console.
440 * @param errorOrWarningMessages Messages are matched partially (i.e. indexOf), pre-formatting.
441 * @param fn
442 */
443 export function withErrorsOrWarningsIgnored<T: void | Promise<void>>(
444 errorOrWarningMessages: string[],
445 fn: () => T,
446 ): T {
447 // withErrorsOrWarningsIgnored() may be nested.
448 const prev = global._ignoredErrorOrWarningMessages || [];
449
450 let resetIgnoredErrorOrWarningMessages = true;
451 try {
452 global._ignoredErrorOrWarningMessages = [
453 ...prev,
454 ...errorOrWarningMessages,
455 ];
456 const maybeThenable = fn();
457 if (
458 maybeThenable !== undefined &&
459 typeof maybeThenable.then === 'function'
460 ) {
461 resetIgnoredErrorOrWarningMessages = false;
462 return maybeThenable.then(
463 () => {
464 global._ignoredErrorOrWarningMessages = prev;
465 },
466 () => {
467 global._ignoredErrorOrWarningMessages = prev;
468 },
469 );
470 }
471 } finally {
472 if (resetIgnoredErrorOrWarningMessages) {
473 global._ignoredErrorOrWarningMessages = prev;
474 }
475 }
476 }
477
478 export function overrideFeatureFlags(overrideFlags) {
479 jest.mock('react-devtools-feature-flags', () => {
480 const actualFlags = jest.requireActual('react-devtools-feature-flags');
481 return {
482 ...actualFlags,
483 ...overrideFlags,
484 };
485 });
486 }
487
488 export function normalizeCodeLocInfo(str) {
489 if (typeof str === 'object' && str !== null) {
490 str = str.stack;
491 }
492 if (typeof str !== 'string') {
493 return str;
494 }
495 // This special case exists only for the special source location in
496 // ReactElementValidator. That will go away if we remove source locations.
497 str = str.replace(/Check your code at .+?:\d+/g, 'Check your code at **');
498 // V8 format:
499 // at Component (/path/filename.js:123:45)
500 // React format:
501 // in Component (at filename.js:123)
502 return str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function (m, name) {
503 return '\n in ' + name + ' (at **)';
504 });
505 }