main
js 670 lines 24.1 KB
Raw
1 /**
2 * Install the hook on window, which is an event emitter.
3 * Note: this global hook __REACT_DEVTOOLS_GLOBAL_HOOK__ is a de facto public API.
4 * It's especially important to avoid creating direct dependency on the DevTools Backend.
5 * That's why we still inline the whole event emitter implementation,
6 * the string format implementation, and part of the console implementation here.
7 *
8 * @flow
9 */
10
11 import type {
12 DevToolsHook,
13 Handler,
14 ReactRenderer,
15 RendererID,
16 RendererInterface,
17 DevToolsBackend,
18 DevToolsHookSettings,
19 ProfilingSettings,
20 ReactBuildType,
21 } from './backend/types';
22 import type {ComponentFilter} from './frontend/types';
23
24 import {
25 FIREFOX_CONSOLE_DIMMING_COLOR,
26 ANSI_STYLE_DIMMING_TEMPLATE,
27 ANSI_STYLE_DIMMING_TEMPLATE_WITH_COMPONENT_STACK,
28 } from 'react-devtools-shared/src/constants';
29 import attachRenderer from './attachRenderer';
30 import formatConsoleArguments from 'react-devtools-shared/src/backend/utils/formatConsoleArguments';
31 import formatWithStyles from 'react-devtools-shared/src/backend/utils/formatWithStyles';
32
33 // React's custom built component stack strings match "\s{4}in"
34 // Chrome's prefix matches "\s{4}at"
35 const PREFIX_REGEX = /\s{4}(in|at)\s{1}/;
36 // Firefox and Safari have no prefix ("")
37 // but we can fallback to looking for location info (e.g. "foo.js:12:345")
38 const ROW_COLUMN_NUMBER_REGEX = /:\d+:\d+(\n|$)/;
39
40 function isStringComponentStack(text: string): boolean {
41 return PREFIX_REGEX.test(text) || ROW_COLUMN_NUMBER_REGEX.test(text);
42 }
43
44 // We add a suffix to some frames that older versions of React didn't do.
45 // To compare if it's equivalent we strip out the suffix to see if they're
46 // still equivalent. Similarly, we sometimes use [] and sometimes () so we
47 // strip them to for the comparison.
48 const frameDiffs = / \(\<anonymous\>\)$|\@unknown\:0\:0$|\(|\)|\[|\]/gm;
49 function areStackTracesEqual(a: string, b: string): boolean {
50 return a.replace(frameDiffs, '') === b.replace(frameDiffs, '');
51 }
52
53 const targetConsole: Object = console;
54
55 const defaultProfilingSettings: ProfilingSettings = {
56 recordChangeDescriptions: false,
57 };
58
59 export function installHook(
60 target: any,
61 componentFiltersOrComponentFiltersPromise:
62 | Array<ComponentFilter>
63 | Promise<Array<ComponentFilter>>,
64 maybeSettingsOrSettingsPromise?:
65 | DevToolsHookSettings
66 | Promise<DevToolsHookSettings>,
67 shouldStartProfilingNow: boolean = false,
68 profilingSettings: ProfilingSettings = defaultProfilingSettings,
69 ): DevToolsHook | null {
70 if (target.hasOwnProperty('__REACT_DEVTOOLS_GLOBAL_HOOK__')) {
71 return null;
72 }
73
74 function detectReactBuildType(renderer: ReactRenderer): ReactBuildType {
75 try {
76 if (typeof renderer.version === 'string') {
77 // React DOM Fiber (16+)
78 if (renderer.bundleType > 0) {
79 // This is not a production build.
80 // We are currently only using 0 (PROD) and 1 (DEV)
81 // but might add 2 (PROFILE) in the future.
82 return 'development';
83 }
84
85 // React 16 uses flat bundles. If we report the bundle as production
86 // version, it means we also minified and envified it ourselves.
87 return 'production';
88 // Note: There is still a risk that the CommonJS entry point has not
89 // been envified or uglified. In this case the user would have *both*
90 // development and production bundle, but only the prod one would run.
91 // This would be really bad. We have a separate check for this because
92 // it happens *outside* of the renderer injection. See `checkDCE` below.
93 }
94
95 // $FlowFixMe[method-unbinding]
96 const toString = Function.prototype.toString;
97 if (renderer.Mount && renderer.Mount._renderNewRootComponent) {
98 // React DOM Stack
99 const renderRootCode = toString.call(
100 renderer.Mount._renderNewRootComponent,
101 );
102 // Filter out bad results (if that is even possible):
103 if (renderRootCode.indexOf('function') !== 0) {
104 // Hope for the best if we're not sure.
105 return 'production';
106 }
107 // Check for React DOM Stack < 15.1.0 in development.
108 // If it contains "storedMeasure" call, it's wrapped in ReactPerf (DEV only).
109 // This would be true even if it's minified, as method name still matches.
110 if (renderRootCode.indexOf('storedMeasure') !== -1) {
111 return 'development';
112 }
113 // For other versions (and configurations) it's not so easy.
114 // Let's quickly exclude proper production builds.
115 // If it contains a warning message, it's either a DEV build,
116 // or an PROD build without proper dead code elimination.
117 if (renderRootCode.indexOf('should be a pure function') !== -1) {
118 // Now how do we tell a DEV build from a bad PROD build?
119 // If we see NODE_ENV, we're going to assume this is a dev build
120 // because most likely it is referring to an empty shim.
121 if (renderRootCode.indexOf('NODE_ENV') !== -1) {
122 return 'development';
123 }
124 // If we see "development", we're dealing with an envified DEV build
125 // (such as the official React DEV UMD).
126 if (renderRootCode.indexOf('development') !== -1) {
127 return 'development';
128 }
129 // I've seen process.env.NODE_ENV !== 'production' being smartly
130 // replaced by `true` in DEV by Webpack. I don't know how that
131 // works but we can safely guard against it because `true` was
132 // never used in the function source since it was written.
133 if (renderRootCode.indexOf('true') !== -1) {
134 return 'development';
135 }
136 // By now either it is a production build that has not been minified,
137 // or (worse) this is a minified development build using non-standard
138 // environment (e.g. "staging"). We're going to look at whether
139 // the function argument name is mangled:
140 if (
141 // 0.13 to 15
142 renderRootCode.indexOf('nextElement') !== -1 ||
143 // 0.12
144 renderRootCode.indexOf('nextComponent') !== -1
145 ) {
146 // We can't be certain whether this is a development build or not,
147 // but it is definitely unminified.
148 return 'unminified';
149 } else {
150 // This is likely a minified development build.
151 return 'development';
152 }
153 }
154 // By now we know that it's envified and dead code elimination worked,
155 // but what if it's still not minified? (Is this even possible?)
156 // Let's check matches for the first argument name.
157 if (
158 // 0.13 to 15
159 renderRootCode.indexOf('nextElement') !== -1 ||
160 // 0.12
161 renderRootCode.indexOf('nextComponent') !== -1
162 ) {
163 return 'unminified';
164 }
165 // Seems like we're using the production version.
166 // However, the branch above is Stack-only so this is 15 or earlier.
167 return 'outdated';
168 }
169 } catch (err) {
170 // Weird environments may exist.
171 // This code needs a higher fault tolerance
172 // because it runs even with closed DevTools.
173 // TODO: should we catch errors in all injected code, and not just this part?
174 }
175 return 'production';
176 }
177
178 function checkDCE(fn: Function) {
179 // This runs for production versions of React.
180 // Needs to be super safe.
181 try {
182 // $FlowFixMe[method-unbinding]
183 const toString = Function.prototype.toString;
184 const code = toString.call(fn);
185
186 // This is a string embedded in the passed function under DEV-only
187 // condition. However the function executes only in PROD. Therefore,
188 // if we see it, dead code elimination did not work.
189 if (code.indexOf('^_^') > -1) {
190 // Remember to report during next injection.
191 hasDetectedBadDCE = true;
192
193 // Bonus: throw an exception hoping that it gets picked up by a reporting system.
194 // Not synchronously so that it doesn't break the calling code.
195 setTimeout(function () {
196 throw new Error(
197 'React is running in production mode, but dead code ' +
198 'elimination has not been applied. Read how to correctly ' +
199 'configure React for production: ' +
200 'https://react.dev/link/perf-use-production-build',
201 );
202 });
203 }
204 } catch (err) {}
205 }
206
207 // TODO: isProfiling should be stateful, and we should update it once profiling is finished
208 const isProfiling = shouldStartProfilingNow;
209 let uidCounter = 0;
210 function inject(renderer: ReactRenderer): number {
211 const id = ++uidCounter;
212 renderers.set(id, renderer);
213
214 const reactBuildType: ReactBuildType = hasDetectedBadDCE
215 ? 'deadcode'
216 : detectReactBuildType(renderer);
217
218 hook.emit('renderer', {
219 id,
220 renderer,
221 reactBuildType,
222 });
223
224 const rendererInterface = attachRenderer(
225 hook,
226 id,
227 renderer,
228 target,
229 isProfiling,
230 profilingSettings,
231 componentFiltersOrComponentFiltersPromise,
232 );
233 if (rendererInterface != null) {
234 hook.rendererInterfaces.set(id, rendererInterface);
235 hook.emit('renderer-attached', {id, rendererInterface});
236 } else {
237 hook.hasUnsupportedRendererAttached = true;
238 hook.emit('unsupported-renderer-version');
239 }
240
241 return id;
242 }
243
244 let hasDetectedBadDCE = false;
245
246 function sub(event: string, fn: Handler) {
247 hook.on(event, fn);
248 return () => hook.off(event, fn);
249 }
250
251 function on(event: string, fn: Handler) {
252 if (!listeners[event]) {
253 listeners[event] = [];
254 }
255 listeners[event].push(fn);
256 }
257
258 function off(event: string, fn: Handler) {
259 if (!listeners[event]) {
260 return;
261 }
262 const index = listeners[event].indexOf(fn);
263 if (index !== -1) {
264 listeners[event].splice(index, 1);
265 }
266 if (!listeners[event].length) {
267 delete listeners[event];
268 }
269 }
270
271 function emit(event: string, data: any) {
272 if (listeners[event]) {
273 listeners[event].map(fn => fn(data));
274 }
275 }
276
277 function getFiberRoots(rendererID: RendererID) {
278 const roots = fiberRoots;
279 if (!roots[rendererID]) {
280 roots[rendererID] = new Set();
281 }
282 return roots[rendererID];
283 }
284
285 function onCommitFiberUnmount(rendererID: RendererID, fiber: any) {
286 const rendererInterface = rendererInterfaces.get(rendererID);
287 if (rendererInterface != null) {
288 rendererInterface.handleCommitFiberUnmount(fiber);
289 }
290 }
291
292 function onCommitFiberRoot(
293 rendererID: RendererID,
294 root: any,
295 priorityLevel: void | number,
296 ) {
297 const mountedRoots = hook.getFiberRoots(rendererID);
298 const current = root.current;
299 const isKnownRoot = mountedRoots.has(root);
300 const isUnmounting =
301 current.memoizedState == null || current.memoizedState.element == null;
302
303 // Keep track of mounted roots so we can hydrate when DevTools connect.
304 if (!isKnownRoot && !isUnmounting) {
305 mountedRoots.add(root);
306 } else if (isKnownRoot && isUnmounting) {
307 mountedRoots.delete(root);
308 }
309 const rendererInterface = rendererInterfaces.get(rendererID);
310 if (rendererInterface != null) {
311 rendererInterface.handleCommitFiberRoot(root, priorityLevel);
312 }
313 }
314
315 function onPostCommitFiberRoot(rendererID: RendererID, root: any) {
316 const rendererInterface = rendererInterfaces.get(rendererID);
317 if (rendererInterface != null) {
318 rendererInterface.handlePostCommitFiberRoot(root);
319 }
320 }
321
322 let isRunningDuringStrictModeInvocation = false;
323 function setStrictMode(rendererID: RendererID, isStrictMode: boolean) {
324 isRunningDuringStrictModeInvocation = isStrictMode;
325
326 if (isStrictMode) {
327 patchConsoleForStrictMode();
328 } else {
329 unpatchConsoleForStrictMode();
330 }
331 }
332
333 const unpatchConsoleCallbacks = [];
334 // For StrictMode we patch console once we are running in StrictMode and unpatch right after it
335 // So patching could happen multiple times during the runtime
336 // Notice how we don't patch error or warn methods, because they are already patched in patchConsoleForErrorsAndWarnings
337 // This will only happen once, when hook is installed
338 function patchConsoleForStrictMode() {
339 // Don't patch console in case settings were not injected
340 if (!hook.settings) {
341 return;
342 }
343
344 // Don't patch twice
345 if (unpatchConsoleCallbacks.length > 0) {
346 return;
347 }
348
349 // At this point 'error', 'warn', and 'trace' methods are already patched
350 // by React DevTools hook to append component stacks and other possible features.
351 const consoleMethodsToOverrideForStrictMode = [
352 'group',
353 'groupCollapsed',
354 'info',
355 'log',
356 ];
357
358 // eslint-disable-next-line no-for-of-loops/no-for-of-loops
359 for (const method of consoleMethodsToOverrideForStrictMode) {
360 const originalMethod = targetConsole[method];
361 const overrideMethod: (...args: Array<any>) => void = (
362 ...args: any[]
363 ) => {
364 const settings = hook.settings;
365 // Something unexpected happened, fallback to just printing the console message.
366 if (settings == null) {
367 originalMethod(...args);
368 return;
369 }
370
371 if (settings.hideConsoleLogsInStrictMode) {
372 return;
373 }
374
375 if (settings.disableSecondConsoleLogDimmingInStrictMode) {
376 // Don't dim the console logs
377 originalMethod(...args);
378 } else {
379 // Dim the text color of the double logs if we're not hiding them.
380 // Firefox doesn't support ANSI escape sequences
381 if (__IS_FIREFOX__) {
382 originalMethod(
383 ...formatWithStyles(args, FIREFOX_CONSOLE_DIMMING_COLOR),
384 );
385 } else {
386 originalMethod(
387 ANSI_STYLE_DIMMING_TEMPLATE,
388 ...formatConsoleArguments(...args),
389 );
390 }
391 }
392 };
393
394 targetConsole[method] = overrideMethod;
395 unpatchConsoleCallbacks.push(() => {
396 targetConsole[method] = originalMethod;
397 });
398 }
399 }
400
401 function unpatchConsoleForStrictMode() {
402 unpatchConsoleCallbacks.forEach(callback => callback());
403 unpatchConsoleCallbacks.length = 0;
404 }
405
406 // For Errors and Warnings we only patch console once
407 function patchConsoleForErrorsAndWarnings() {
408 // Don't patch console in case settings were not injected
409 if (!hook.settings) {
410 return;
411 }
412
413 const consoleMethodsToOverrideForErrorsAndWarnings = [
414 'error',
415 'trace',
416 'warn',
417 ];
418
419 // eslint-disable-next-line no-for-of-loops/no-for-of-loops
420 for (const method of consoleMethodsToOverrideForErrorsAndWarnings) {
421 const originalMethod = targetConsole[method];
422 const overrideMethod: (...args: Array<any>) => void = (...args) => {
423 const settings = hook.settings;
424 // Something unexpected happened, fallback to just printing the console message.
425 if (settings == null) {
426 originalMethod(...args);
427 return;
428 }
429
430 if (
431 isRunningDuringStrictModeInvocation &&
432 settings.hideConsoleLogsInStrictMode
433 ) {
434 return;
435 }
436
437 let injectedComponentStackAsFakeError = false;
438 let alreadyHasComponentStack = false;
439 if (settings.appendComponentStack) {
440 const lastArg = args.length > 0 ? args[args.length - 1] : null;
441 alreadyHasComponentStack =
442 typeof lastArg === 'string' && isStringComponentStack(lastArg); // The last argument should be a component stack.
443 }
444
445 const shouldShowInlineWarningsAndErrors =
446 settings.showInlineWarningsAndErrors &&
447 (method === 'error' || method === 'warn');
448
449 // Search for the first renderer that has a current Fiber.
450 // We don't handle the edge case of stacks for more than one (e.g. interleaved renderers?)
451 // eslint-disable-next-line no-for-of-loops/no-for-of-loops
452 for (const rendererInterface of hook.rendererInterfaces.values()) {
453 const {onErrorOrWarning, getComponentStack} = rendererInterface;
454 try {
455 if (shouldShowInlineWarningsAndErrors) {
456 // patch() is called by two places: (1) the hook and (2) the renderer backend.
457 // The backend is what implements a message queue, so it's the only one that injects onErrorOrWarning.
458 if (onErrorOrWarning != null) {
459 onErrorOrWarning(
460 method as any as 'error' | 'warn',
461 args.slice(),
462 );
463 }
464 }
465 } catch (error) {
466 // Don't let a DevTools or React internal error interfere with logging.
467 setTimeout(() => {
468 throw error;
469 }, 0);
470 }
471
472 try {
473 if (settings.appendComponentStack && getComponentStack != null) {
474 // This needs to be directly in the wrapper so we can pop exactly one frame.
475 const topFrame = Error('react-stack-top-frame');
476 const match = getComponentStack(topFrame);
477 if (match !== null) {
478 const {enableOwnerStacks, componentStack} = match;
479 // Empty string means we have a match but no component stack.
480 // We don't need to look in other renderers but we also don't add anything.
481 if (componentStack !== '') {
482 // Create a fake Error so that when we print it we get native source maps. Every
483 // browser will print the .stack property of the error and then parse it back for source
484 // mapping. Rather than print the internal slot. So it doesn't matter that the internal
485 // slot doesn't line up.
486 const fakeError = new Error('');
487 // In Chromium, only the stack property is printed but in Firefox the <name>:<message>
488 // gets printed so to make the colon make sense, we name it so we print Stack:
489 // and similarly Safari leave an expandable slot.
490 if (__IS_CHROME__ || __IS_EDGE__) {
491 // Before sending the stack to Chrome DevTools for formatting,
492 // V8 will reconstruct this according to the template <name>: <message><stack-frames>
493 // https://source.chromium.org/chromium/chromium/src/+/main:v8/src/inspector/value-mirror.cc;l=252-311;drc=bdc48d1b1312cc40c00282efb1c9c5f41dcdca9a
494 // It has to start with ^[\w.]*Error\b to trigger stack formatting.
495 fakeError.name = enableOwnerStacks
496 ? 'Error Stack'
497 : 'Error Component Stack'; // This gets printed
498 } else {
499 fakeError.name = enableOwnerStacks
500 ? 'Stack'
501 : 'Component Stack'; // This gets printed
502 }
503 // In Chromium, the stack property needs to start with ^[\w.]*Error\b to trigger stack
504 // formatting. Otherwise it is left alone. So we prefix it. Otherwise we just override it
505 // to our own stack.
506 fakeError.stack =
507 __IS_CHROME__ || __IS_EDGE__ || __IS_NATIVE__
508 ? (enableOwnerStacks
509 ? 'Error Stack:'
510 : 'Error Component Stack:') + componentStack
511 : componentStack;
512
513 if (alreadyHasComponentStack) {
514 // Only modify the component stack if it matches what we would've added anyway.
515 // Otherwise we assume it was a non-React stack.
516 if (
517 areStackTracesEqual(args[args.length - 1], componentStack)
518 ) {
519 const firstArg = args[0];
520 if (
521 args.length > 1 &&
522 typeof firstArg === 'string' &&
523 firstArg.endsWith('%s')
524 ) {
525 args[0] = firstArg.slice(0, firstArg.length - 2); // Strip the %s param
526 }
527 args[args.length - 1] = fakeError;
528 injectedComponentStackAsFakeError = true;
529 }
530 } else {
531 args.push(fakeError);
532 injectedComponentStackAsFakeError = true;
533 }
534 }
535
536 // Don't add stacks from other renderers.
537 break;
538 }
539 }
540 } catch (error) {
541 // Don't let a DevTools or React internal error interfere with logging.
542 setTimeout(() => {
543 throw error;
544 }, 0);
545 }
546 }
547
548 if (settings.breakOnConsoleErrors) {
549 // --- Welcome to debugging with React DevTools ---
550 // This debugger statement means that you've enabled the "break on warnings" feature.
551 // Use the browser's Call Stack panel to step out of this override function
552 // to where the original warning or error was logged.
553 // eslint-disable-next-line no-debugger
554 debugger;
555 }
556
557 if (
558 isRunningDuringStrictModeInvocation &&
559 !settings.disableSecondConsoleLogDimmingInStrictMode
560 ) {
561 // Dim the text color of the double logs if we're not hiding them.
562 // Firefox doesn't support ANSI escape sequences
563 if (__IS_FIREFOX__) {
564 let argsWithCSSStyles = formatWithStyles(
565 args,
566 FIREFOX_CONSOLE_DIMMING_COLOR,
567 );
568
569 if (injectedComponentStackAsFakeError) {
570 argsWithCSSStyles = [
571 `${argsWithCSSStyles[0]} %o`,
572 ...argsWithCSSStyles.slice(1),
573 ];
574 }
575
576 originalMethod(...argsWithCSSStyles);
577 } else {
578 originalMethod(
579 injectedComponentStackAsFakeError
580 ? ANSI_STYLE_DIMMING_TEMPLATE_WITH_COMPONENT_STACK
581 : ANSI_STYLE_DIMMING_TEMPLATE,
582 ...formatConsoleArguments(...args),
583 );
584 }
585 } else {
586 originalMethod(...args);
587 }
588 };
589
590 targetConsole[method] = overrideMethod;
591 }
592 }
593
594 // TODO: More meaningful names for "rendererInterfaces" and "renderers".
595 const fiberRoots: {[RendererID]: Set<mixed>} = {};
596 const rendererInterfaces = new Map<RendererID, RendererInterface>();
597 const listeners: {[string]: Array<Handler>} = {};
598 const renderers = new Map<RendererID, ReactRenderer>();
599 const backends = new Map<string, DevToolsBackend>();
600
601 const hook: DevToolsHook = {
602 rendererInterfaces,
603 listeners,
604
605 backends,
606
607 // Fast Refresh for web relies on this.
608 renderers,
609 hasUnsupportedRendererAttached: false,
610
611 emit,
612 getFiberRoots,
613 inject,
614 on,
615 off,
616 sub,
617
618 // This is a legacy flag.
619 // React v16 checks the hook for this to ensure DevTools is new enough.
620 supportsFiber: true,
621
622 // React Flight Client checks the hook for this to ensure DevTools is new enough.
623 supportsFlight: true,
624
625 // React calls these methods.
626 checkDCE,
627 onCommitFiberUnmount,
628 onCommitFiberRoot,
629 // React v18.0+
630 onPostCommitFiberRoot,
631 setStrictMode,
632 };
633
634 if (maybeSettingsOrSettingsPromise == null) {
635 // Set default settings
636 hook.settings = {
637 appendComponentStack: true,
638 breakOnConsoleErrors: false,
639 showInlineWarningsAndErrors: true,
640 hideConsoleLogsInStrictMode: false,
641 disableSecondConsoleLogDimmingInStrictMode: false,
642 };
643 patchConsoleForErrorsAndWarnings();
644 } else {
645 Promise.resolve(maybeSettingsOrSettingsPromise)
646 .then(settings => {
647 hook.settings = settings;
648 hook.emit('settingsInitialized', settings);
649
650 patchConsoleForErrorsAndWarnings();
651 })
652 .catch(() => {
653 targetConsole.error(
654 "React DevTools failed to get Console Patching settings. Console won't be patched and some console features will not work.",
655 );
656 });
657 }
658
659 Object.defineProperty(target, '__REACT_DEVTOOLS_GLOBAL_HOOK__', {
660 // This property needs to be configurable for the test environment,
661 // else we won't be able to delete and recreate it between tests.
662 configurable: __DEV__,
663 enumerable: false,
664 get() {
665 return hook;
666 },
667 } as Object);
668
669 return hook;
670 }