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