Patch console to append component stacks (#348)

* Patch console.warn and console.error to auto-append owners-only component stacks.

This setting is enabled by default and will work for React Native even if no front-end DevTools shell is being used. The setting can be disabled via a new, persisted user preference though.
This commit is contained in:
Brian Vaughn
2019-07-17 11:12:39 -07:00
committed by GitHub
parent 0f2fb5badf
commit cb3fb42129
24 changed files with 1085 additions and 257 deletions

View File

@@ -66,7 +66,7 @@ declare module 'react-test-renderer' {
options?: TestRendererOptions
): ReactTestRenderer;
declare function act(callback: () => void): Thenable;
declare function act(callback: () => ?Thenable): Thenable;
}
declare module 'react-test-renderer/shallow' {

View File

@@ -138,18 +138,18 @@
"opener": "^1.5.1",
"prettier": "^1.16.4",
"prop-types": "^15.6.2",
"react": "^0.0.0-50b50c26f",
"react": "^0.0.0-424099da6",
"react-15": "npm:react@^15",
"react-color": "^2.11.7",
"react-dom": "^0.0.0-50b50c26f",
"react-dom": "^0.0.0-424099da6",
"react-dom-15": "npm:react-dom@^15",
"react-is": "^0.0.0-50b50c26f",
"react-test-renderer": "^0.0.0-50b50c26f",
"react-is": "0.0.0-424099da6",
"react-test-renderer": "^0.0.0-424099da6",
"react-virtualized-auto-sizer": "^1.0.2",
"react-window": "./vendor/react-window",
"request-promise": "^4.2.4",
"rimraf": "^2.6.3",
"scheduler": "^0.0.0-50b50c26f",
"scheduler": "^0.0.0-424099da6",
"semver": "^5.5.1",
"serve-static": "^1.14.1",
"style-loader": "^0.23.1",

View File

@@ -9,7 +9,7 @@ import {
} from 'react-dom';
import Bridge from 'src/bridge';
import Store from 'src/devtools/store';
import { getSavedComponentFilters } from 'src/utils';
import { getSavedComponentFilters, getAppendComponentStack } from 'src/utils';
import { Server } from 'ws';
import { existsSync, readFileSync } from 'fs';
import { installHook } from 'src/hook';
@@ -241,12 +241,16 @@ function startServer(port?: number = 8097) {
// because they are generally stored in localStorage within the context of the extension.
// Because of this it relies on the extension to pass filters, so include them wth the response here.
// This will ensure that saved filters are shared across different web pages.
const savedFiltersString = `window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = ${JSON.stringify(
getSavedComponentFilters()
)};`;
const savedPreferencesString = `
window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = ${JSON.stringify(
getSavedComponentFilters()
)};
window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ = ${JSON.stringify(
getAppendComponentStack()
)};`;
response.end(
savedFiltersString +
savedPreferencesString +
'\n;' +
backendFile.toString() +
'\n;' +

View File

@@ -6,7 +6,7 @@ import Bridge from 'src/bridge';
import Store from 'src/devtools/store';
import inject from './inject';
import { createViewElementSource, getBrowserTheme } from './utils';
import { getSavedComponentFilters } from 'src/utils';
import { getSavedComponentFilters, getAppendComponentStack } from 'src/utils';
import {
localStorageGetItem,
localStorageRemoveItem,
@@ -22,16 +22,23 @@ let panelCreated = false;
// The renderer interface can't read saved component filters directly,
// because they are stored in localStorage within the context of the extension.
// Instead it relies on the extension to pass filters through.
function initializeSavedComponentFilters() {
function syncSavedPreferences() {
const componentFilters = getSavedComponentFilters();
chrome.devtools.inspectedWindow.eval(
`window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = ${JSON.stringify(
componentFilters
)};`
);
const appendComponentStack = getAppendComponentStack();
chrome.devtools.inspectedWindow.eval(
`window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ = ${JSON.stringify(
appendComponentStack
)};`
);
}
initializeSavedComponentFilters();
syncSavedPreferences();
function createPanelIfReactLoaded() {
if (panelCreated) {
@@ -286,7 +293,7 @@ function createPanelIfReactLoaded() {
chrome.devtools.network.onNavigated.addListener(function onNavigated() {
// Re-initialize saved filters on navigation,
// since global values stored on window get reset in this case.
initializeSavedComponentFilters();
syncSavedPreferences();
// It's easiest to recreate the DevTools panel (to clean up potential stale state).
// We can revisit this in the future as a small optimization.
@@ -302,7 +309,7 @@ function createPanelIfReactLoaded() {
// Load (or reload) the DevTools extension when the user navigates to a new page.
function checkPageForReact() {
initializeSavedComponentFilters();
syncSavedPreferences();
createPanelIfReactLoaded();
}

View File

@@ -8,7 +8,7 @@ import { installHook } from 'src/hook';
import { initDevTools } from 'src/devtools';
import Store from 'src/devtools/store';
import DevTools from 'src/devtools/views/DevTools';
import { getSavedComponentFilters } from 'src/utils';
import { getSavedComponentFilters, getAppendComponentStack } from 'src/utils';
const iframe = ((document.getElementById('target'): any): HTMLIFrameElement);
@@ -18,6 +18,7 @@ const { contentDocument, contentWindow } = iframe;
// because they are stored in localStorage within the context of the extension.
// Instead it relies on the extension to pass filters through.
contentWindow.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = getSavedComponentFilters();
contentWindow.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ = getAppendComponentStack();
installHook(contentWindow);

View File

@@ -0,0 +1,349 @@
// @flow
describe('console', () => {
let React;
let ReactDOM;
let act;
let enableConsole;
let disableConsole;
let fakeConsole;
let mockError;
let mockLog;
let mockWarn;
let patchConsole;
let unpatchConsole;
beforeEach(() => {
const Console = require('../backend/console');
enableConsole = Console.enable;
disableConsole = Console.disable;
patchConsole = Console.patch;
unpatchConsole = Console.unpatch;
const inject = global.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject;
global.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject = internals => {
inject(internals);
Console.registerRenderer(internals);
};
React = require('react');
ReactDOM = require('react-dom');
const utils = require('./utils');
act = utils.act;
// Patch a fake console so we can verify with tests below.
// Patching the real console is too complicated,
// because Jest itself has hooks into it as does our test env setup.
mockError = jest.fn();
mockLog = jest.fn();
mockWarn = jest.fn();
fakeConsole = {
error: mockError,
log: mockLog,
warn: mockWarn,
};
patchConsole(fakeConsole);
});
function normalizeCodeLocInfo(str) {
return str && str.replace(/\(at .+?:\d+\)/g, '(at **)');
}
it('should only patch the console once', () => {
const { error, warn } = fakeConsole;
patchConsole(fakeConsole);
expect(fakeConsole.error).toBe(error);
expect(fakeConsole.warn).toBe(warn);
});
it('should un-patch when requested', () => {
expect(fakeConsole.error).not.toBe(mockError);
expect(fakeConsole.warn).not.toBe(mockWarn);
unpatchConsole();
expect(fakeConsole.error).toBe(mockError);
expect(fakeConsole.warn).toBe(mockWarn);
});
it('should pass through logs when there is no current fiber', () => {
expect(mockLog).toHaveBeenCalledTimes(0);
expect(mockWarn).toHaveBeenCalledTimes(0);
expect(mockError).toHaveBeenCalledTimes(0);
fakeConsole.log('log');
fakeConsole.warn('warn');
fakeConsole.error('error');
expect(mockLog).toHaveBeenCalledTimes(1);
expect(mockLog.mock.calls[0]).toHaveLength(1);
expect(mockLog.mock.calls[0][0]).toBe('log');
expect(mockWarn).toHaveBeenCalledTimes(1);
expect(mockWarn.mock.calls[0]).toHaveLength(1);
expect(mockWarn.mock.calls[0][0]).toBe('warn');
expect(mockError).toHaveBeenCalledTimes(1);
expect(mockError.mock.calls[0]).toHaveLength(1);
expect(mockError.mock.calls[0][0]).toBe('error');
});
it('should suppress console logging when disabled', () => {
disableConsole();
fakeConsole.log('log');
fakeConsole.warn('warn');
fakeConsole.error('error');
expect(mockLog).toHaveBeenCalledTimes(0);
expect(mockWarn).toHaveBeenCalledTimes(0);
expect(mockError).toHaveBeenCalledTimes(0);
enableConsole();
fakeConsole.log('log');
fakeConsole.warn('warn');
fakeConsole.error('error');
expect(mockLog).toHaveBeenCalledTimes(1);
expect(mockLog.mock.calls[0]).toHaveLength(1);
expect(mockLog.mock.calls[0][0]).toBe('log');
expect(mockWarn).toHaveBeenCalledTimes(1);
expect(mockWarn.mock.calls[0]).toHaveLength(1);
expect(mockWarn.mock.calls[0][0]).toBe('warn');
expect(mockError).toHaveBeenCalledTimes(1);
expect(mockError.mock.calls[0]).toHaveLength(1);
expect(mockError.mock.calls[0][0]).toBe('error');
});
it('should not append multiple stacks', () => {
const Child = () => {
fakeConsole.warn('warn\n in Child (at fake.js:123)');
fakeConsole.error('error', '\n in Child (at fake.js:123)');
return null;
};
act(() => ReactDOM.render(<Child />, document.createElement('div')));
expect(mockWarn).toHaveBeenCalledTimes(1);
expect(mockWarn.mock.calls[0]).toHaveLength(1);
expect(mockWarn.mock.calls[0][0]).toBe(
'warn\n in Child (at fake.js:123)'
);
expect(mockError).toHaveBeenCalledTimes(1);
expect(mockError.mock.calls[0]).toHaveLength(2);
expect(mockError.mock.calls[0][0]).toBe('error');
expect(mockError.mock.calls[0][1]).toBe('\n in Child (at fake.js:123)');
});
it('should append component stacks to errors and warnings logged during render', () => {
const Intermediate = ({ children }) => children;
const Parent = () => (
<Intermediate>
<Child />
</Intermediate>
);
const Child = () => {
fakeConsole.error('error');
fakeConsole.log('log');
fakeConsole.warn('warn');
return null;
};
act(() => ReactDOM.render(<Parent />, document.createElement('div')));
expect(mockLog).toHaveBeenCalledTimes(1);
expect(mockLog.mock.calls[0]).toHaveLength(1);
expect(mockLog.mock.calls[0][0]).toBe('log');
expect(mockWarn).toHaveBeenCalledTimes(1);
expect(mockWarn.mock.calls[0]).toHaveLength(2);
expect(mockWarn.mock.calls[0][0]).toBe('warn');
expect(normalizeCodeLocInfo(mockWarn.mock.calls[0][1])).toEqual(
'\n in Child (at **)\n in Parent (at **)'
);
expect(mockError).toHaveBeenCalledTimes(1);
expect(mockError.mock.calls[0]).toHaveLength(2);
expect(mockError.mock.calls[0][0]).toBe('error');
expect(normalizeCodeLocInfo(mockError.mock.calls[0][1])).toBe(
'\n in Child (at **)\n in Parent (at **)'
);
});
it('should append component stacks to errors and warnings logged from effects', () => {
const Intermediate = ({ children }) => children;
const Parent = () => (
<Intermediate>
<Child />
</Intermediate>
);
const Child = () => {
React.useLayoutEffect(() => {
fakeConsole.error('active error');
fakeConsole.log('active log');
fakeConsole.warn('active warn');
});
React.useEffect(() => {
fakeConsole.error('passive error');
fakeConsole.log('passive log');
fakeConsole.warn('passive warn');
});
return null;
};
act(() => ReactDOM.render(<Parent />, document.createElement('div')));
expect(mockLog).toHaveBeenCalledTimes(2);
expect(mockLog.mock.calls[0]).toHaveLength(1);
expect(mockLog.mock.calls[0][0]).toBe('active log');
expect(mockLog.mock.calls[1]).toHaveLength(1);
expect(mockLog.mock.calls[1][0]).toBe('passive log');
expect(mockWarn).toHaveBeenCalledTimes(2);
expect(mockWarn.mock.calls[0]).toHaveLength(2);
expect(mockWarn.mock.calls[0][0]).toBe('active warn');
expect(normalizeCodeLocInfo(mockWarn.mock.calls[0][1])).toEqual(
'\n in Child (at **)\n in Parent (at **)'
);
expect(mockWarn.mock.calls[1]).toHaveLength(2);
expect(mockWarn.mock.calls[1][0]).toBe('passive warn');
expect(normalizeCodeLocInfo(mockWarn.mock.calls[1][1])).toEqual(
'\n in Child (at **)\n in Parent (at **)'
);
expect(mockError).toHaveBeenCalledTimes(2);
expect(mockError.mock.calls[0]).toHaveLength(2);
expect(mockError.mock.calls[0][0]).toBe('active error');
expect(normalizeCodeLocInfo(mockError.mock.calls[0][1])).toBe(
'\n in Child (at **)\n in Parent (at **)'
);
expect(mockError.mock.calls[1]).toHaveLength(2);
expect(mockError.mock.calls[1][0]).toBe('passive error');
expect(normalizeCodeLocInfo(mockError.mock.calls[1][1])).toBe(
'\n in Child (at **)\n in Parent (at **)'
);
});
it('should append component stacks to errors and warnings logged from commit hooks', () => {
const Intermediate = ({ children }) => children;
const Parent = () => (
<Intermediate>
<Child />
</Intermediate>
);
class Child extends React.Component<any> {
componentDidMount() {
fakeConsole.error('didMount error');
fakeConsole.log('didMount log');
fakeConsole.warn('didMount warn');
}
componentDidUpdate() {
fakeConsole.error('didUpdate error');
fakeConsole.log('didUpdate log');
fakeConsole.warn('didUpdate warn');
}
render() {
return null;
}
}
const container = document.createElement('div');
act(() => ReactDOM.render(<Parent />, container));
act(() => ReactDOM.render(<Parent />, container));
expect(mockLog).toHaveBeenCalledTimes(2);
expect(mockLog.mock.calls[0]).toHaveLength(1);
expect(mockLog.mock.calls[0][0]).toBe('didMount log');
expect(mockLog.mock.calls[1]).toHaveLength(1);
expect(mockLog.mock.calls[1][0]).toBe('didUpdate log');
expect(mockWarn).toHaveBeenCalledTimes(2);
expect(mockWarn.mock.calls[0]).toHaveLength(2);
expect(mockWarn.mock.calls[0][0]).toBe('didMount warn');
expect(normalizeCodeLocInfo(mockWarn.mock.calls[0][1])).toEqual(
'\n in Child (at **)\n in Parent (at **)'
);
expect(mockWarn.mock.calls[1]).toHaveLength(2);
expect(mockWarn.mock.calls[1][0]).toBe('didUpdate warn');
expect(normalizeCodeLocInfo(mockWarn.mock.calls[1][1])).toEqual(
'\n in Child (at **)\n in Parent (at **)'
);
expect(mockError).toHaveBeenCalledTimes(2);
expect(mockError.mock.calls[0]).toHaveLength(2);
expect(mockError.mock.calls[0][0]).toBe('didMount error');
expect(normalizeCodeLocInfo(mockError.mock.calls[0][1])).toBe(
'\n in Child (at **)\n in Parent (at **)'
);
expect(mockError.mock.calls[1]).toHaveLength(2);
expect(mockError.mock.calls[1][0]).toBe('didUpdate error');
expect(normalizeCodeLocInfo(mockError.mock.calls[1][1])).toBe(
'\n in Child (at **)\n in Parent (at **)'
);
});
it('should append component stacks to errors and warnings logged from gDSFP', () => {
const Intermediate = ({ children }) => children;
const Parent = () => (
<Intermediate>
<Child />
</Intermediate>
);
class Child extends React.Component<any, any> {
state = {};
static getDerivedStateFromProps() {
fakeConsole.error('error');
fakeConsole.log('log');
fakeConsole.warn('warn');
return null;
}
render() {
return null;
}
}
act(() => ReactDOM.render(<Parent />, document.createElement('div')));
expect(mockLog).toHaveBeenCalledTimes(1);
expect(mockLog.mock.calls[0]).toHaveLength(1);
expect(mockLog.mock.calls[0][0]).toBe('log');
expect(mockWarn).toHaveBeenCalledTimes(1);
expect(mockWarn.mock.calls[0]).toHaveLength(2);
expect(mockWarn.mock.calls[0][0]).toBe('warn');
expect(normalizeCodeLocInfo(mockWarn.mock.calls[0][1])).toEqual(
'\n in Child (at **)\n in Parent (at **)'
);
expect(mockError).toHaveBeenCalledTimes(1);
expect(mockError.mock.calls[0]).toHaveLength(2);
expect(mockError.mock.calls[0][0]).toBe('error');
expect(normalizeCodeLocInfo(mockError.mock.calls[0][1])).toBe(
'\n in Child (at **)\n in Parent (at **)'
);
});
it('should append stacks after being uninstalled and reinstalled', () => {
const Child = () => {
fakeConsole.warn('warn');
fakeConsole.error('error');
return null;
};
unpatchConsole();
act(() => ReactDOM.render(<Child />, document.createElement('div')));
expect(mockWarn).toHaveBeenCalledTimes(1);
expect(mockWarn.mock.calls[0]).toHaveLength(1);
expect(mockWarn.mock.calls[0][0]).toBe('warn');
expect(mockError).toHaveBeenCalledTimes(1);
expect(mockError.mock.calls[0]).toHaveLength(1);
expect(mockError.mock.calls[0][0]).toBe('error');
patchConsole(fakeConsole);
act(() => ReactDOM.render(<Child />, document.createElement('div')));
expect(mockWarn).toHaveBeenCalledTimes(2);
expect(mockWarn.mock.calls[1]).toHaveLength(2);
expect(mockWarn.mock.calls[1][0]).toBe('warn');
expect(normalizeCodeLocInfo(mockWarn.mock.calls[1][1])).toEqual(
'\n in Child (at **)'
);
expect(mockError).toHaveBeenCalledTimes(2);
expect(mockError.mock.calls[1]).toHaveLength(2);
expect(mockError.mock.calls[1][0]).toBe('error');
expect(normalizeCodeLocInfo(mockError.mock.calls[1][1])).toBe(
'\n in Child (at **)'
);
});
});

View File

@@ -530,16 +530,20 @@ describe('InspectedElementContext', () => {
inspectedElement = null;
TestUtils.act(() => {
getInspectedElementPath(id, ['props', 'nestedObject', 'a']);
jest.runOnlyPendingTimers();
TestRenderer.act(() => {
getInspectedElementPath(id, ['props', 'nestedObject', 'a']);
jest.runOnlyPendingTimers();
});
});
expect(inspectedElement).not.toBeNull();
expect(inspectedElement).toMatchSnapshot('2: Inspect props.nestedObject.a');
inspectedElement = null;
TestUtils.act(() => {
getInspectedElementPath(id, ['props', 'nestedObject', 'a', 'b', 'c']);
jest.runOnlyPendingTimers();
TestRenderer.act(() => {
getInspectedElementPath(id, ['props', 'nestedObject', 'a', 'b', 'c']);
jest.runOnlyPendingTimers();
});
});
expect(inspectedElement).not.toBeNull();
expect(inspectedElement).toMatchSnapshot(
@@ -548,16 +552,18 @@ describe('InspectedElementContext', () => {
inspectedElement = null;
TestUtils.act(() => {
getInspectedElementPath(id, [
'props',
'nestedObject',
'a',
'b',
'c',
0,
'd',
]);
jest.runOnlyPendingTimers();
TestRenderer.act(() => {
getInspectedElementPath(id, [
'props',
'nestedObject',
'a',
'b',
'c',
0,
'd',
]);
jest.runOnlyPendingTimers();
});
});
expect(inspectedElement).not.toBeNull();
expect(inspectedElement).toMatchSnapshot(
@@ -566,16 +572,20 @@ describe('InspectedElementContext', () => {
inspectedElement = null;
TestUtils.act(() => {
getInspectedElementPath(id, ['hooks', 0, 'value']);
jest.runOnlyPendingTimers();
TestRenderer.act(() => {
getInspectedElementPath(id, ['hooks', 0, 'value']);
jest.runOnlyPendingTimers();
});
});
expect(inspectedElement).not.toBeNull();
expect(inspectedElement).toMatchSnapshot('5: Inspect hooks.0.value');
inspectedElement = null;
TestUtils.act(() => {
getInspectedElementPath(id, ['hooks', 0, 'value', 'foo', 'bar']);
jest.runOnlyPendingTimers();
TestRenderer.act(() => {
getInspectedElementPath(id, ['hooks', 0, 'value', 'foo', 'bar']);
jest.runOnlyPendingTimers();
});
});
expect(inspectedElement).not.toBeNull();
expect(inspectedElement).toMatchSnapshot(
@@ -645,7 +655,7 @@ describe('InspectedElementContext', () => {
expect(inspectedElement).toMatchSnapshot('1: Initially inspect element');
inspectedElement = null;
TestUtils.act(() => {
TestRenderer.act(() => {
getInspectedElementPath(id, ['props', 'nestedObject', 'a']);
jest.runOnlyPendingTimers();
});
@@ -653,44 +663,46 @@ describe('InspectedElementContext', () => {
expect(inspectedElement).toMatchSnapshot('2: Inspect props.nestedObject.a');
inspectedElement = null;
TestUtils.act(() => {
TestRenderer.act(() => {
getInspectedElementPath(id, ['props', 'nestedObject', 'c']);
jest.runOnlyPendingTimers();
});
expect(inspectedElement).not.toBeNull();
expect(inspectedElement).toMatchSnapshot('3: Inspect props.nestedObject.c');
TestUtils.act(() => {
ReactDOM.render(
<Example
nestedObject={{
a: {
value: 2,
b: {
TestRenderer.act(() => {
TestUtils.act(() => {
ReactDOM.render(
<Example
nestedObject={{
a: {
value: 2,
},
},
c: {
value: 2,
d: {
value: 2,
e: {
b: {
value: 2,
},
},
},
}}
/>,
container
);
c: {
value: 2,
d: {
value: 2,
e: {
value: 2,
},
},
},
}}
/>,
container
);
});
});
TestUtils.act(() => {
TestRenderer.act(() => {
inspectedElement = null;
jest.advanceTimersByTime(1000);
expect(inspectedElement).not.toBeNull();
expect(inspectedElement).toMatchSnapshot('4: update inspected element');
});
expect(inspectedElement).not.toBeNull();
expect(inspectedElement).toMatchSnapshot('4: update inspected element');
done();
});
@@ -764,9 +776,12 @@ describe('InspectedElementContext', () => {
});
inspectedElement = null;
TestUtils.act(() => {
getInspectedElementPath(id, ['props', 'nestedObject', 'a']);
jest.runOnlyPendingTimers();
TestRenderer.act(() => {
TestUtils.act(() => {
getInspectedElementPath(id, ['props', 'nestedObject', 'a']);
jest.runOnlyPendingTimers();
});
});
expect(inspectedElement).not.toBeNull();
expect(inspectedElement).toMatchSnapshot('2: Inspect props.nestedObject.a');

View File

@@ -34,7 +34,7 @@ describe('ProfilingCache', () => {
it('should collect data for each root (including ones added or mounted after profiling started)', () => {
const Parent = ({ count }) => {
Scheduler.advanceTime(10);
Scheduler.unstable_advanceTime(10);
const children = new Array(count)
.fill(true)
.map((_, index) => <Child key={index} duration={index} />);
@@ -46,7 +46,7 @@ describe('ProfilingCache', () => {
);
};
const Child = ({ duration }) => {
Scheduler.advanceTime(duration);
Scheduler.unstable_advanceTime(duration);
return null;
};
const MemoizedChild = React.memo(Child);
@@ -118,7 +118,7 @@ describe('ProfilingCache', () => {
it('should collect data for each commit', () => {
const Parent = ({ count }) => {
Scheduler.advanceTime(10);
Scheduler.unstable_advanceTime(10);
const children = new Array(count)
.fill(true)
.map((_, index) => <Child key={index} duration={index} />);
@@ -130,7 +130,7 @@ describe('ProfilingCache', () => {
);
};
const Child = ({ duration }) => {
Scheduler.advanceTime(duration);
Scheduler.unstable_advanceTime(duration);
return null;
};
const MemoizedChild = React.memo(Child);
@@ -305,7 +305,7 @@ describe('ProfilingCache', () => {
store.componentFilters = [utils.createDisplayNameFilter('^Parent$')];
const Grandparent = () => {
Scheduler.advanceTime(10);
Scheduler.unstable_advanceTime(10);
return (
<React.Fragment>
<Parent key="one" />
@@ -314,11 +314,11 @@ describe('ProfilingCache', () => {
);
};
const Parent = () => {
Scheduler.advanceTime(2);
Scheduler.unstable_advanceTime(2);
return <Child />;
};
const Child = () => {
Scheduler.advanceTime(1);
Scheduler.unstable_advanceTime(1);
return null;
};
@@ -361,7 +361,7 @@ describe('ProfilingCache', () => {
};
const Parent = () => {
Scheduler.advanceTime(10);
Scheduler.unstable_advanceTime(10);
return (
<React.Suspense fallback={<Fallback />}>
<Async />
@@ -369,11 +369,11 @@ describe('ProfilingCache', () => {
);
};
const Fallback = () => {
Scheduler.advanceTime(2);
Scheduler.unstable_advanceTime(2);
return 'Fallback...';
};
const Async = () => {
Scheduler.advanceTime(3);
Scheduler.unstable_advanceTime(3);
const data = getData();
return data;
};
@@ -412,7 +412,7 @@ describe('ProfilingCache', () => {
it('should collect data for each rendered fiber', () => {
const Parent = ({ count }) => {
Scheduler.advanceTime(10);
Scheduler.unstable_advanceTime(10);
const children = new Array(count)
.fill(true)
.map((_, index) => <Child key={index} duration={index} />);
@@ -424,7 +424,7 @@ describe('ProfilingCache', () => {
);
};
const Child = ({ duration }) => {
Scheduler.advanceTime(duration);
Scheduler.unstable_advanceTime(duration);
return null;
};
const MemoizedChild = React.memo(Child);
@@ -496,7 +496,7 @@ describe('ProfilingCache', () => {
it('should report every traced interaction', () => {
const Parent = ({ count }) => {
Scheduler.advanceTime(10);
Scheduler.unstable_advanceTime(10);
const children = new Array(count)
.fill(true)
.map((_, index) => <Child key={index} duration={index} />);
@@ -508,7 +508,7 @@ describe('ProfilingCache', () => {
);
};
const Child = ({ duration }) => {
Scheduler.advanceTime(duration);
Scheduler.unstable_advanceTime(duration);
return null;
};
const MemoizedChild = React.memo(Child);

View File

@@ -30,7 +30,7 @@ describe('profiling charts', () => {
describe('flamegraph chart', () => {
it('should contain valid data', () => {
const Parent = ({ count }) => {
Scheduler.advanceTime(10);
Scheduler.unstable_advanceTime(10);
return (
<React.Fragment>
<Child key="first" duration={3} />
@@ -42,7 +42,7 @@ describe('profiling charts', () => {
// Memoize children to verify that chart doesn't include in the update.
const Child = React.memo(function Child({ duration }) {
Scheduler.advanceTime(duration);
Scheduler.unstable_advanceTime(duration);
return null;
});
@@ -106,7 +106,7 @@ describe('profiling charts', () => {
describe('ranked chart', () => {
it('should contain valid data', () => {
const Parent = ({ count }) => {
Scheduler.advanceTime(10);
Scheduler.unstable_advanceTime(10);
return (
<React.Fragment>
<Child key="first" duration={3} />
@@ -118,7 +118,7 @@ describe('profiling charts', () => {
// Memoize children to verify that chart doesn't include in the update.
const Child = React.memo(function Child({ duration }) {
Scheduler.advanceTime(duration);
Scheduler.unstable_advanceTime(duration);
return null;
});
@@ -178,7 +178,7 @@ describe('profiling charts', () => {
describe('interactions', () => {
it('should contain valid data', () => {
const Parent = ({ count }) => {
Scheduler.advanceTime(10);
Scheduler.unstable_advanceTime(10);
return (
<React.Fragment>
<Child key="first" duration={3} />
@@ -190,7 +190,7 @@ describe('profiling charts', () => {
// Memoize children to verify that chart doesn't include in the update.
const Child = React.memo(function Child({ duration }) {
Scheduler.advanceTime(duration);
Scheduler.unstable_advanceTime(duration);
return null;
});

View File

@@ -27,13 +27,13 @@ describe('commit tree', () => {
it('should be able to rebuild the store tree for each commit', () => {
const Parent = ({ count }) => {
Scheduler.advanceTime(10);
Scheduler.unstable_advanceTime(10);
return new Array(count)
.fill(true)
.map((_, index) => <Child key={index} />);
};
const Child = React.memo(function Child() {
Scheduler.advanceTime(2);
Scheduler.unstable_advanceTime(2);
return null;
});

View File

@@ -8,14 +8,20 @@ import type { ProfilingDataFrontend } from 'src/devtools/views/Profiler/types';
import type { ElementType } from 'src/types';
export function act(callback: Function): void {
const TestUtils = require('react-dom/test-utils');
TestUtils.act(() => {
callback();
const { act: actTestRenderer } = require('react-test-renderer');
const { act: actDOM } = require('react-dom/test-utils');
actDOM(() => {
actTestRenderer(() => {
callback();
});
});
// Flush Bridge operations
TestUtils.act(() => {
jest.runAllTimers();
actDOM(() => {
actTestRenderer(() => {
jest.runAllTimers();
});
});
}
@@ -23,24 +29,31 @@ export async function actAsync(
cb: () => *,
recursivelyFlush: boolean = true
): Promise<void> {
const TestUtils = require('react-dom/test-utils');
const { act: actTestRenderer } = require('react-test-renderer');
const { act: actDOM } = require('react-dom/test-utils');
// $FlowFixMe Flow doens't know about "await act()" yet
await TestUtils.act(async () => {
await cb();
await actDOM(async () => {
await actTestRenderer(async () => {
await cb();
});
});
if (recursivelyFlush) {
while (jest.getTimerCount() > 0) {
// $FlowFixMe Flow doens't know about "await act()" yet
await TestUtils.act(async () => {
jest.runAllTimers();
await actDOM(async () => {
await actTestRenderer(async () => {
jest.runAllTimers();
});
});
}
} else {
// $FlowFixMe Flow doesn't know about "await act()" yet
await TestUtils.act(async () => {
jest.runOnlyPendingTimers();
await actDOM(async () => {
await actTestRenderer(async () => {
jest.runOnlyPendingTimers();
});
});
}
}
@@ -122,10 +135,17 @@ export function getRendererID(): number {
throw Error('Agent unavailable.');
}
const ids = Object.keys(global.agent._rendererInterfaces);
if (ids.length !== 1) {
throw Error('Multiple renderers attached.');
const id = ids.find(id => {
const rendererInterface = global.agent._rendererInterfaces[id];
return rendererInterface.renderer.rendererPackageName === 'react-dom';
});
if (ids == null) {
throw Error('Could not find renderer.');
}
return parseInt(ids[0], 10);
return parseInt(id, 10);
}
export function requireTestRenderer(): ReactTestRenderer {

View File

@@ -15,6 +15,7 @@ import {
sessionStorageSetItem,
} from 'src/storage';
import setupHighlighter from './views/Highlighter';
import { patch as patchConsole, unpatch as unpatchConsole } from './console';
import type {
InstanceAndStyle,
@@ -133,6 +134,10 @@ export default class Agent extends EventEmitter<{|
this.syncSelectionFromNativeElementsPanel
);
bridge.addListener('shutdown', this.shutdown);
bridge.addListener(
'updateAppendComponentStack',
this.updateAppendComponentStack
);
bridge.addListener('updateComponentFilters', this.updateComponentFilters);
bridge.addListener('viewElementSource', this.viewElementSource);
@@ -402,6 +407,18 @@ export default class Agent extends EventEmitter<{|
this._bridge.send('profilingStatus', this._isProfiling);
};
updateAppendComponentStack = (appendComponentStack: boolean) => {
// If the frontend preference has change,
// or in the case of React Native- if the backend is just finding out the preference-
// then install or uninstall the console overrides.
// It's safe to call these methods multiple times, so we don't need to worry about that.
if (appendComponentStack) {
patchConsole();
} else {
unpatchConsole();
}
};
updateComponentFilters = (componentFilters: Array<ComponentFilter>) => {
for (let rendererID in this._rendererInterfaces) {
const renderer = ((this._rendererInterfaces[

125
src/backend/console.js Normal file
View File

@@ -0,0 +1,125 @@
// @flow
import { getInternalReactConstants } from './renderer';
import describeComponentFrame from './describeComponentFrame';
import type { Fiber, ReactRenderer } from './types';
const FRAME_REGEX = /\n {4}in /;
const injectedRenderers: Map<
ReactRenderer,
{|
getCurrentFiber: () => Fiber | null,
getDisplayNameForFiber: (fiber: Fiber) => string | null,
|}
> = new Map();
let isDisabled: boolean = false;
let unpatchFn: null | (() => void) = null;
export function disable(): void {
isDisabled = true;
}
export function enable(): void {
isDisabled = false;
}
export function registerRenderer(renderer: ReactRenderer): void {
const { getCurrentFiber, findFiberByHostInstance, version } = renderer;
// Ignore React v15 and older because they don't expose a component stack anyway.
if (typeof findFiberByHostInstance !== 'function') {
return;
}
if (typeof getCurrentFiber === 'function') {
const { getDisplayNameForFiber } = getInternalReactConstants(version);
injectedRenderers.set(renderer, {
getCurrentFiber,
getDisplayNameForFiber,
});
}
}
export function patch(targetConsole?: Object = console): void {
if (unpatchFn !== null) {
// Don't patch twice.
return;
}
const originalConsoleMethods = { ...targetConsole };
unpatchFn = () => {
for (let method in targetConsole) {
try {
// $FlowFixMe property error|warn is not writable.
targetConsole[method] = originalConsoleMethods[method];
} catch (error) {}
}
};
for (let method in targetConsole) {
const appendComponentStack =
method === 'error' || method === 'warn' || method === 'trace';
const originalMethod = targetConsole[method];
const overrideMethod = (...args) => {
if (isDisabled) return;
if (appendComponentStack) {
// If we are ever called with a string that already has a component stack, e.g. a React error/warning,
// don't append a second stack.
const alreadyHasComponentStack =
args.length > 0 && FRAME_REGEX.exec(args[args.length - 1]);
if (!alreadyHasComponentStack) {
// If there's a component stack for at least one of the injected renderers, append it.
// We don't handle the edge case of stacks for more than one (e.g. interleaved renderers?)
for (let {
getCurrentFiber,
getDisplayNameForFiber,
} of injectedRenderers.values()) {
let current: ?Fiber = getCurrentFiber();
let ownerStack: string = '';
while (current != null) {
const name = getDisplayNameForFiber(current);
const owner = current._debugOwner;
const ownerName =
owner != null ? getDisplayNameForFiber(owner) : null;
ownerStack += describeComponentFrame(
name,
current._debugSource,
ownerName
);
current = owner;
}
if (ownerStack !== '') {
args.push(ownerStack);
break;
}
}
}
}
originalMethod(...args);
};
try {
// $FlowFixMe property error|warn is not writable.
targetConsole[method] = overrideMethod;
} catch (error) {}
}
}
export function unpatch(): void {
if (unpatchFn !== null) {
unpatchFn();
unpatchFn = null;
}
}

View File

@@ -0,0 +1,41 @@
// @flow
// This file was forked from the React GitHub repo:
// https://raw.githubusercontent.com/facebook/react/master/packages/shared/describeComponentFrame.js
//
// It has been modified sligthly to add a zero width space as commented below.
const BEFORE_SLASH_RE = /^(.*)[\\/]/;
export default function describeComponentFrame(
name: null | string,
source: any,
ownerName: null | string
) {
let sourceInfo = '';
if (source) {
let path = source.fileName;
let fileName = path.replace(BEFORE_SLASH_RE, '');
if (__DEV__) {
// In DEV, include code for a common special case:
// prefer "folder/index.js" instead of just "index.js".
if (/^index\./.test(fileName)) {
const match = path.match(BEFORE_SLASH_RE);
if (match) {
const pathBeforeSlash = match[1];
if (pathBeforeSlash) {
const folderName = pathBeforeSlash.replace(BEFORE_SLASH_RE, '');
// Note the below string contains a zero width space after the "/" character.
// This is to prevent browsers like Chrome from formatting the file name as a link.
// (Since this is a source link, it would not work to open the source file anyway.)
fileName = folderName + '/' + fileName;
}
}
}
}
sourceInfo = ' (at ' + fileName + ':' + source.lineNumber + ')';
} else if (ownerName) {
sourceInfo = ' (created by ' + ownerName + ')';
}
return '\n in ' + (name || 'Unknown') + sourceInfo;
}

View File

@@ -1,11 +1,12 @@
// @flow
import type { DevToolsHook, ReactRenderer, RendererInterface } from './types';
import Agent from './agent';
import { attach } from './renderer';
import { attach as attachLegacy } from './legacy/renderer';
import type { DevToolsHook, ReactRenderer, RendererInterface } from './types';
export function initBackend(
hook: DevToolsHook,
agent: Agent,

View File

@@ -39,6 +39,12 @@ import {
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
} from '../constants';
import { inspectHooksOfFiber } from './ReactDebugHooks';
import {
disable as disableConsole,
enable as enableConsole,
patch as patchConsole,
registerRenderer as registerRendererWithConsole,
} from './console';
import type {
ChangeDescription,
@@ -59,8 +65,89 @@ import type {
import type { Interaction } from 'src/devtools/views/Profiler/types';
import type { ComponentFilter, ElementType } from 'src/types';
function getInternalReactConstants(version) {
const ReactSymbols = {
type getDisplayNameForFiberType = (fiber: Fiber) => string | null;
type getTypeSymbolType = (type: any) => Symbol | number;
type ReactSymbolsType = {
CONCURRENT_MODE_NUMBER: number,
CONCURRENT_MODE_SYMBOL_STRING: string,
DEPRECATED_ASYNC_MODE_SYMBOL_STRING: string,
CONTEXT_CONSUMER_NUMBER: number,
CONTEXT_CONSUMER_SYMBOL_STRING: string,
CONTEXT_PROVIDER_NUMBER: number,
CONTEXT_PROVIDER_SYMBOL_STRING: string,
EVENT_COMPONENT_NUMBER: number,
EVENT_COMPONENT_STRING: string,
EVENT_TARGET_NUMBER: number,
EVENT_TARGET_STRING: string,
EVENT_TARGET_TOUCH_HIT_NUMBER: number,
EVENT_TARGET_TOUCH_HIT_STRING: string,
FORWARD_REF_NUMBER: number,
FORWARD_REF_SYMBOL_STRING: string,
MEMO_NUMBER: number,
MEMO_SYMBOL_STRING: string,
PROFILER_NUMBER: number,
PROFILER_SYMBOL_STRING: string,
STRICT_MODE_NUMBER: number,
STRICT_MODE_SYMBOL_STRING: string,
SUSPENSE_NUMBER: number,
SUSPENSE_SYMBOL_STRING: string,
DEPRECATED_PLACEHOLDER_SYMBOL_STRING: string,
};
type ReactPriorityLevelsType = {|
ImmediatePriority: number,
UserBlockingPriority: number,
NormalPriority: number,
LowPriority: number,
IdlePriority: number,
NoPriority: number,
|};
type ReactTypeOfWorkType = {|
ClassComponent: number,
ContextConsumer: number,
ContextProvider: number,
CoroutineComponent: number,
CoroutineHandlerPhase: number,
DehydratedSuspenseComponent: number,
EventComponent: number,
EventTarget: number,
ForwardRef: number,
Fragment: number,
FunctionComponent: number,
HostComponent: number,
HostPortal: number,
HostRoot: number,
HostText: number,
IncompleteClassComponent: number,
IndeterminateComponent: number,
LazyComponent: number,
MemoComponent: number,
Mode: number,
Profiler: number,
SimpleMemoComponent: number,
SuspenseComponent: number,
YieldComponent: number,
|};
type ReactTypeOfSideEffectType = {|
NoEffect: number,
PerformedWork: number,
Placement: number,
|};
export function getInternalReactConstants(
version: string
): {|
getDisplayNameForFiber: getDisplayNameForFiberType,
getTypeSymbol: getTypeSymbolType,
ReactPriorityLevels: ReactPriorityLevelsType,
ReactSymbols: ReactSymbolsType,
ReactTypeOfSideEffect: ReactTypeOfSideEffectType,
ReactTypeOfWork: ReactTypeOfWorkType,
|} {
const ReactSymbols: ReactSymbolsType = {
CONCURRENT_MODE_NUMBER: 0xeacf,
CONCURRENT_MODE_SYMBOL_STRING: 'Symbol(react.concurrent_mode)',
DEPRECATED_ASYNC_MODE_SYMBOL_STRING: 'Symbol(react.async_mode)',
@@ -87,7 +174,7 @@ function getInternalReactConstants(version) {
DEPRECATED_PLACEHOLDER_SYMBOL_STRING: 'Symbol(react.placeholder)',
};
const ReactTypeOfSideEffect = {
const ReactTypeOfSideEffect: ReactTypeOfSideEffectType = {
NoEffect: 0b00,
PerformedWork: 0b01,
Placement: 0b10,
@@ -100,7 +187,7 @@ function getInternalReactConstants(version) {
// Technically these priority levels are invalid for versions before 16.9,
// but 16.9 is the first version to report priority level to DevTools,
// so we can avoid checking for earlier versions and support pre-16.9 canary releases in the process.
const ReactPriorityLevels = {
const ReactPriorityLevels: ReactPriorityLevelsType = {
ImmediatePriority: 99,
UserBlockingPriority: 98,
NormalPriority: 97,
@@ -109,7 +196,7 @@ function getInternalReactConstants(version) {
NoPriority: 90,
};
let ReactTypeOfWork;
let ReactTypeOfWork: ReactTypeOfWorkType = ((null: any): ReactTypeOfWorkType);
// **********************************************************
// The section below is copied from files in React repo.
@@ -200,7 +287,149 @@ function getInternalReactConstants(version) {
// End of copied code.
// **********************************************************
function getTypeSymbol(type: any): Symbol | number {
const symbolOrNumber =
typeof type === 'object' && type !== null ? type.$$typeof : type;
return typeof symbolOrNumber === 'symbol'
? symbolOrNumber.toString()
: symbolOrNumber;
}
const {
ClassComponent,
IncompleteClassComponent,
FunctionComponent,
IndeterminateComponent,
EventComponent,
EventTarget,
ForwardRef,
HostRoot,
HostComponent,
HostPortal,
HostText,
Fragment,
MemoComponent,
SimpleMemoComponent,
} = ReactTypeOfWork;
const {
EVENT_TARGET_TOUCH_HIT_NUMBER,
EVENT_TARGET_TOUCH_HIT_STRING,
CONCURRENT_MODE_NUMBER,
CONCURRENT_MODE_SYMBOL_STRING,
DEPRECATED_ASYNC_MODE_SYMBOL_STRING,
CONTEXT_PROVIDER_NUMBER,
CONTEXT_PROVIDER_SYMBOL_STRING,
CONTEXT_CONSUMER_NUMBER,
CONTEXT_CONSUMER_SYMBOL_STRING,
STRICT_MODE_NUMBER,
STRICT_MODE_SYMBOL_STRING,
SUSPENSE_NUMBER,
SUSPENSE_SYMBOL_STRING,
DEPRECATED_PLACEHOLDER_SYMBOL_STRING,
PROFILER_NUMBER,
PROFILER_SYMBOL_STRING,
} = ReactSymbols;
// NOTICE Keep in sync with shouldFilterFiber() and other get*ForFiber methods
function getDisplayNameForFiber(fiber: Fiber): string | null {
const { elementType, type, tag } = fiber;
// This is to support lazy components with a Promise as the type.
// see https://github.com/facebook/react/pull/13397
let resolvedType = type;
if (typeof type === 'object' && type !== null) {
if (typeof type.then === 'function') {
resolvedType = type._reactResult;
}
}
let resolvedContext: any = null;
switch (tag) {
case ClassComponent:
case IncompleteClassComponent:
return getDisplayName(resolvedType);
case FunctionComponent:
case IndeterminateComponent:
return getDisplayName(resolvedType);
case EventComponent:
return type.responder.displayName || 'EventComponent';
case EventTarget:
switch (getTypeSymbol(elementType.type)) {
case EVENT_TARGET_TOUCH_HIT_NUMBER:
case EVENT_TARGET_TOUCH_HIT_STRING:
return 'TouchHitTarget';
default:
return elementType.displayName || 'EventTarget';
}
case ForwardRef:
return (
resolvedType.displayName ||
getDisplayName(resolvedType.render, 'Anonymous')
);
case HostRoot:
return null;
case HostComponent:
return type;
case HostPortal:
case HostText:
case Fragment:
return null;
case MemoComponent:
case SimpleMemoComponent:
if (elementType.displayName) {
return elementType.displayName;
} else {
return getDisplayName(type, 'Anonymous');
}
default:
const typeSymbol = getTypeSymbol(type);
switch (typeSymbol) {
case CONCURRENT_MODE_NUMBER:
case CONCURRENT_MODE_SYMBOL_STRING:
case DEPRECATED_ASYNC_MODE_SYMBOL_STRING:
return null;
case CONTEXT_PROVIDER_NUMBER:
case CONTEXT_PROVIDER_SYMBOL_STRING:
// 16.3.0 exposed the context object as "context"
// PR #12501 changed it to "_context" for 16.3.1+
// NOTE Keep in sync with inspectElementRaw()
resolvedContext = fiber.type._context || fiber.type.context;
return `${resolvedContext.displayName || 'Context'}.Provider`;
case CONTEXT_CONSUMER_NUMBER:
case CONTEXT_CONSUMER_SYMBOL_STRING:
// 16.3-16.5 read from "type" because the Consumer is the actual context object.
// 16.6+ should read from "type._context" because Consumer can be different (in DEV).
// NOTE Keep in sync with inspectElementRaw()
resolvedContext = fiber.type._context || fiber.type;
// NOTE: TraceUpdatesBackendManager depends on the name ending in '.Consumer'
// If you change the name, figure out a more resilient way to detect it.
return `${resolvedContext.displayName || 'Context'}.Consumer`;
case STRICT_MODE_NUMBER:
case STRICT_MODE_SYMBOL_STRING:
return null;
case SUSPENSE_NUMBER:
case SUSPENSE_SYMBOL_STRING:
case DEPRECATED_PLACEHOLDER_SYMBOL_STRING:
return 'Suspense';
case PROFILER_NUMBER:
case PROFILER_SYMBOL_STRING:
return `Profiler(${fiber.memoizedProps.id})`;
default:
// Unknown element type.
// This may mean a new element type that has not yet been added to DevTools.
return null;
}
}
}
return {
getDisplayNameForFiber,
getTypeSymbol,
ReactPriorityLevels,
ReactTypeOfWork,
ReactSymbols,
@@ -215,6 +444,8 @@ export function attach(
global: Object
): RendererInterface {
const {
getDisplayNameForFiber,
getTypeSymbol,
ReactPriorityLevels,
ReactTypeOfWork,
ReactSymbols,
@@ -256,8 +487,6 @@ export function attach(
CONTEXT_CONSUMER_SYMBOL_STRING,
CONTEXT_PROVIDER_NUMBER,
CONTEXT_PROVIDER_SYMBOL_STRING,
EVENT_TARGET_TOUCH_HIT_NUMBER,
EVENT_TARGET_TOUCH_HIT_STRING,
PROFILER_NUMBER,
PROFILER_SYMBOL_STRING,
STRICT_MODE_NUMBER,
@@ -277,6 +506,22 @@ export function attach(
typeof setSuspenseHandler === 'function' &&
typeof scheduleUpdate === 'function';
// Patching the console enables DevTools to do a few useful things:
// * Append component stacks to warnings and error messages
// * Disable logging during re-renders to inspect hooks (see inspectHooksOfFiber)
//
// Don't patch in test environments because we don't want to interfere with Jest's own console overrides.
if (process.env.NODE_ENV !== 'test') {
registerRendererWithConsole(renderer);
// The renderer interface can't read this preference directly,
// because it is stored in localStorage within the context of the extension.
// It relies on the extension to pass the preference through via the global.
if (window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ !== false) {
patchConsole();
}
}
const debug = (name: string, fiber: Fiber, parentFiber: ?Fiber): void => {
if (__DEBUG__) {
const displayName = getDisplayNameForFiber(fiber) || 'null';
@@ -345,7 +590,10 @@ export function attach(
if (window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ != null) {
applyComponentFilters(window.__REACT_DEVTOOLS_COMPONENT_FILTERS__);
} else {
console.warn('⚛️ DevTools: Could not locate saved component filters');
// Unfortunately this feature is not expected to work for React Native for now.
// It would be annoying for us to spam YellowBox warnings with unactionable stuff,
// so for now just skip this message...
//console.warn('⚛️ DevTools: Could not locate saved component filters');
// Fallback to assuming the default filters in this case.
applyComponentFilters(getDefaultComponentFilters());
@@ -447,111 +695,6 @@ export function attach(
return false;
}
function getTypeSymbol(type: any): Symbol | number {
const symbolOrNumber =
typeof type === 'object' && type !== null ? type.$$typeof : type;
return typeof symbolOrNumber === 'symbol'
? symbolOrNumber.toString()
: symbolOrNumber;
}
// NOTICE Keep in sync with shouldFilterFiber() and other get*ForFiber methods
function getDisplayNameForFiber(fiber: Fiber): string | null {
const { elementType, type, tag } = fiber;
// This is to support lazy components with a Promise as the type.
// see https://github.com/facebook/react/pull/13397
let resolvedType = type;
if (typeof type === 'object' && type !== null) {
if (typeof type.then === 'function') {
resolvedType = type._reactResult;
}
}
let resolvedContext: any = null;
switch (tag) {
case ClassComponent:
case IncompleteClassComponent:
return getDisplayName(resolvedType);
case FunctionComponent:
case IndeterminateComponent:
return getDisplayName(resolvedType);
case EventComponent:
return type.responder.displayName || 'EventComponent';
case EventTarget:
switch (getTypeSymbol(elementType.type)) {
case EVENT_TARGET_TOUCH_HIT_NUMBER:
case EVENT_TARGET_TOUCH_HIT_STRING:
return 'TouchHitTarget';
default:
return elementType.displayName || 'EventTarget';
}
case ForwardRef:
return (
resolvedType.displayName ||
getDisplayName(resolvedType.render, 'Anonymous')
);
case HostRoot:
return null;
case HostComponent:
return type;
case HostPortal:
case HostText:
case Fragment:
return null;
case MemoComponent:
case SimpleMemoComponent:
if (elementType.displayName) {
return elementType.displayName;
} else {
return getDisplayName(type, 'Anonymous');
}
default:
const typeSymbol = getTypeSymbol(type);
switch (typeSymbol) {
case CONCURRENT_MODE_NUMBER:
case CONCURRENT_MODE_SYMBOL_STRING:
case DEPRECATED_ASYNC_MODE_SYMBOL_STRING:
return null;
case CONTEXT_PROVIDER_NUMBER:
case CONTEXT_PROVIDER_SYMBOL_STRING:
// 16.3.0 exposed the context object as "context"
// PR #12501 changed it to "_context" for 16.3.1+
// NOTE Keep in sync with inspectElementRaw()
resolvedContext = fiber.type._context || fiber.type.context;
return `${resolvedContext.displayName || 'Context'}.Provider`;
case CONTEXT_CONSUMER_NUMBER:
case CONTEXT_CONSUMER_SYMBOL_STRING:
// 16.3-16.5 read from "type" because the Consumer is the actual context object.
// 16.6+ should read from "type._context" because Consumer can be different (in DEV).
// NOTE Keep in sync with inspectElementRaw()
resolvedContext = fiber.type._context || fiber.type;
// NOTE: TraceUpdatesBackendManager depends on the name ending in '.Consumer'
// If you change the name, figure out a more resilient way to detect it.
return `${resolvedContext.displayName || 'Context'}.Consumer`;
case STRICT_MODE_NUMBER:
case STRICT_MODE_SYMBOL_STRING:
return null;
case SUSPENSE_NUMBER:
case SUSPENSE_SYMBOL_STRING:
case DEPRECATED_PLACEHOLDER_SYMBOL_STRING:
return 'Suspense';
case PROFILER_NUMBER:
case PROFILER_SYMBOL_STRING:
return `Profiler(${fiber.memoizedProps.id})`;
default:
// Unknown element type.
// This may mean a new element type that has not yet been added to DevTools.
return null;
}
}
}
// NOTICE Keep in sync with shouldFilterFiber() and other get*ForFiber methods
function getElementTypeForFiber(fiber: Fiber): ElementType {
const { type, tag } = fiber;
@@ -2109,6 +2252,20 @@ export function attach(
node = node.return;
}
let hooks = null;
if (usesHooks) {
// Suppress console logging while re-rendering
try {
disableConsole();
hooks = inspectHooksOfFiber(
fiber,
(renderer.currentDispatcherRef: any)
);
} finally {
enableConsole();
}
}
return {
id,
@@ -2136,9 +2293,7 @@ export function attach(
// TODO Review sanitization approach for the below inspectable values.
context,
events,
hooks: usesHooks
? inspectHooksOfFiber(fiber, (renderer.currentDispatcherRef: any))
: null,
hooks,
props: memoizedProps,
state: usesHooks ? null : memoizedState,

View File

@@ -118,6 +118,10 @@ export type ReactRenderer = {
// Only injected by React v16.8+ in order to support hooks inspection.
currentDispatcherRef?: {| current: null | Dispatcher |},
// Only injected by React v16.9+ in DEV mode.
// Enables DevTools to append owners-only component stack to error messages.
getCurrentFiber?: () => Fiber | null,
// <= 15
Mount?: any,
};

View File

@@ -98,6 +98,7 @@ export default class Bridge extends EventEmitter<{|
stopProfiling: [],
syncSelectionFromNativeElementsPanel: [],
syncSelectionToNativeElementsPanel: [],
updateAppendComponentStack: [boolean],
updateComponentFilters: [Array<ComponentFilter>],
viewElementSource: [ElementAndRendererID],

View File

@@ -20,4 +20,7 @@ export const SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY =
export const SESSION_STORAGE_RELOAD_AND_PROFILE_KEY =
'React::DevTools::reloadAndProfile';
export const LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY =
'React::DevTools::appendComponentStack';
export const PROFILER_EXPORT_VERSION = 4;

View File

@@ -1,27 +1,25 @@
// @flow
import React, { useCallback, useContext } from 'react';
import React, { useContext } from 'react';
import { SettingsContext } from './SettingsContext';
import styles from './SettingsShared.css';
export default function GeneralSettings(_: {||}) {
const { displayDensity, setDisplayDensity, theme, setTheme } = useContext(
SettingsContext
);
const {
displayDensity,
setDisplayDensity,
theme,
setTheme,
appendComponentStack,
setAppendComponentStack,
} = useContext(SettingsContext);
const updateDisplayDensity = useCallback(
({ currentTarget }) => {
setDisplayDensity(currentTarget.value);
},
[setDisplayDensity]
);
const updateTheme = useCallback(
({ currentTarget }) => {
setTheme(currentTarget.value);
},
[setTheme]
);
const updateDisplayDensity = ({ currentTarget }) =>
setDisplayDensity(currentTarget.value);
const updateTheme = ({ currentTarget }) => setTheme(currentTarget.value);
const updateappendComponentStack = ({ currentTarget }) =>
setAppendComponentStack(currentTarget.checked);
return (
<div className={styles.Settings}>
@@ -45,6 +43,17 @@ export default function GeneralSettings(_: {||}) {
<option value="comfortable">Comfortable</option>
</select>
</div>
<div className={styles.Setting}>
<label>
<input
type="checkbox"
checked={appendComponentStack}
onChange={updateappendComponentStack}
/>{' '}
Append component stacks to console warnings and errors.
</label>
</div>
</div>
);
}

View File

@@ -1,7 +1,15 @@
// @flow
import React, { createContext, useLayoutEffect, useMemo } from 'react';
import React, {
createContext,
useContext,
useEffect,
useLayoutEffect,
useMemo,
} from 'react';
import { LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY } from 'src/constants';
import { useLocalStorage } from '../hooks';
import { BridgeContext } from '../context';
import type { BrowserTheme } from '../DevTools';
@@ -16,6 +24,9 @@ type Context = {|
// Specified as a separate prop so it can trigger a re-render of FixedSizeList.
lineHeight: number,
appendComponentStack: boolean,
setAppendComponentStack: (value: boolean) => void,
theme: Theme,
setTheme(value: Theme): void,
|};
@@ -40,6 +51,8 @@ function SettingsContextController({
profilerPortalContainer,
settingsPortalContainer,
}: Props) {
const bridge = useContext(BridgeContext);
const [displayDensity, setDisplayDensity] = useLocalStorage<DisplayDensity>(
'React::DevTools::displayDensity',
'compact'
@@ -48,6 +61,10 @@ function SettingsContextController({
'React::DevTools::theme',
'auto'
);
const [
appendComponentStack,
setAppendComponentStack,
] = useLocalStorage<boolean>(LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY, true);
const documentElements = useMemo<DocumentElements>(() => {
const array: Array<HTMLElement> = [
@@ -117,12 +134,18 @@ function SettingsContextController({
}
}, [browserTheme, theme, documentElements]);
useEffect(() => {
bridge.send('updateAppendComponentStack', appendComponentStack);
}, [bridge, appendComponentStack]);
const value = useMemo(
() => ({
displayDensity,
setDisplayDensity,
theme,
setTheme,
appendComponentStack,
setAppendComponentStack,
lineHeight:
displayDensity === 'compact'
? compactLineHeight
@@ -134,6 +157,8 @@ function SettingsContextController({
displayDensity,
setDisplayDensity,
setTheme,
appendComponentStack,
setAppendComponentStack,
theme,
]
);

View File

@@ -7,6 +7,11 @@
* @flow
*/
import {
patch as patchConsole,
registerRenderer as registerRendererWithConsole,
} from './backend/console';
import type { DevToolsHook } from 'src/backend/types';
declare var window: any;
@@ -155,6 +160,32 @@ export function installHook(target: any): DevToolsHook | null {
? 'deadcode'
: detectReactBuildType(renderer);
// Patching the console enables DevTools to do a few useful things:
// * Append component stacks to warnings and error messages
// * Disable logging during re-renders to inspect hooks (see inspectHooksOfFiber)
//
// For React Native, we intentionally patch early (during injection).
// This provides React Native developers with components stacks even if they don't run DevTools.
// This won't work for DOM though, since this entire file is eval'ed and inserted as a script tag.
// In that case, we'll patch later (when the frontend attaches).
//
// Don't patch in test environments because we don't want to interfere with Jest's own console overrides.
if (process.env.NODE_ENV !== 'test') {
try {
// The installHook() function is injected by being stringified in the browser,
// so imports outside of this function do not get included.
//
// Normally we could check "typeof patchConsole === 'function'",
// but Webpack wraps imports with an object (e.g. _backend_console__WEBPACK_IMPORTED_MODULE_0__)
// and the object itself will be undefined as well for the reasons mentioned above,
// so we use try/catch instead.
if (window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ !== false) {
registerRendererWithConsole(renderer);
patchConsole();
}
} catch (error) {}
}
// If we have just reloaded to profile, we need to inject the renderer interface before the app loads.
// Otherwise the renderer won't yet exist and we can skip this step.
const attach = target.__REACT_DEVTOOLS_ATTACH__;

View File

@@ -8,7 +8,10 @@ import {
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
} from './constants';
import { ElementTypeRoot } from 'src/types';
import { LOCAL_STORAGE_FILTER_PREFERENCES_KEY } from './constants';
import {
LOCAL_STORAGE_FILTER_PREFERENCES_KEY,
LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY,
} from './constants';
import { ComponentFilterElementType, ElementTypeHostComponent } from './types';
import {
ElementTypeClass,
@@ -198,6 +201,23 @@ export function saveComponentFilters(
);
}
export function getAppendComponentStack(): boolean {
try {
const raw = localStorageGetItem(LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY);
if (raw != null) {
return JSON.parse(raw);
}
} catch (error) {}
return true;
}
export function setAppendComponentStack(value: boolean): void {
localStorageSetItem(
LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY,
JSON.stringify(value)
);
}
export function separateDisplayNameAndHOCs(
displayName: string | null,
type: ElementType

View File

@@ -9944,20 +9944,20 @@ react-color@^2.11.7:
object-assign "^4.1.0"
prop-types "^15.5.10"
react-dom@^0.0.0-50b50c26f:
version "0.0.0-50b50c26f"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-0.0.0-50b50c26f.tgz#3cd8da0f2276ed4b7a926e1807d2675b2eb40227"
integrity sha512-da9qleWDdBdAguEIDvvpFE0iuS8hfcCSGgZTYKRQMlSh5A94Ktr1otL4rgDTFH+bNsOwz3XrvEBYRA6WaE9xzQ==
react-dom@^0.0.0-424099da6:
version "0.0.0-424099da6"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-0.0.0-424099da6.tgz#2a6392d5730fd7688d7ec5a56b2f9f8e3627d271"
integrity sha512-B6x2YWaw06xJ0br9wsny6a9frqmaNAw+vGrm08W7JZp7WfiBYKhM6Nt9seijaAVzajp49fe18orNMgL12Lafsg==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
prop-types "^15.6.2"
scheduler "0.0.0-50b50c26f"
scheduler "0.0.0-424099da6"
react-is@0.0.0-50b50c26f, react-is@^0.0.0-50b50c26f:
version "0.0.0-50b50c26f"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-0.0.0-50b50c26f.tgz#c4003ffffef9bd2b287979f9041a23d12a607bf2"
integrity sha512-9Y6ZvdOVmOxXs9mGuFy6eXHBww8RJCtJAh94b1hkbjhnW8Mb5ADScDoxJBVxcNuX9hvDkhENspC96ZQK1NIv3g==
react-is@0.0.0-424099da6:
version "0.0.0-424099da6"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-0.0.0-424099da6.tgz#a8b1322bbb1ef014b33ee3bff30d3be9a4729baa"
integrity sha512-VMFvIdqNV0eB8YxmE9katf3XM4qbdKGhLYANfohwktTryrWWOUOoVRX6IHm4iN06LgHwWLBOBP/YARc1qzuF2w==
react-is@^16.8.1:
version "16.8.3"
@@ -9969,15 +9969,15 @@ react-is@^16.8.4:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.4.tgz#90f336a68c3a29a096a3d648ab80e87ec61482a2"
integrity sha512-PVadd+WaUDOAciICm/J1waJaSvgq+4rHE/K70j0PFqKhkTBsPv/82UGQJNXAngz1fOQLLxI6z1sEDmJDQhCTAA==
react-test-renderer@^0.0.0-50b50c26f:
version "0.0.0-50b50c26f"
resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-0.0.0-50b50c26f.tgz#1a85cf9073ef5a932d03bee36fcfd9bf15aeae2c"
integrity sha512-gWc4L+mFIUCjvBpafR88n4/i/oaKHD6rzVyZY+XBou9MNtr2rRkjePOhBVsiYlCwkj+zZi6klV9b05TMzftosA==
react-test-renderer@^0.0.0-424099da6:
version "0.0.0-424099da6"
resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-0.0.0-424099da6.tgz#75272c39e0b45e99dbd674977a4afc32a2d37f81"
integrity sha512-tF9NutO52Js52L390poDUvnN43j77SekXyIt0KzQa+HdgY7uvvzKH9ouqCz3M5f52eDZDff4OG4cZuZ+lNLIfQ==
dependencies:
object-assign "^4.1.1"
prop-types "^15.6.2"
react-is "0.0.0-50b50c26f"
scheduler "0.0.0-50b50c26f"
react-is "0.0.0-424099da6"
scheduler "0.0.0-424099da6"
react-virtualized-auto-sizer@^1.0.2:
version "1.0.2"
@@ -9990,10 +9990,10 @@ react-window@./vendor/react-window:
"@babel/runtime" "^7.0.0"
memoize-one ">=3.1.1 <6"
react@^0.0.0-50b50c26f:
version "0.0.0-50b50c26f"
resolved "https://registry.yarnpkg.com/react/-/react-0.0.0-50b50c26f.tgz#b782b579ce1f5d8bd696c5e45c744714ebecb111"
integrity sha512-jUAzS4DeWTdUZ/3kqm2T6C9OIpiAf2qdwVamCts0qzwYVni1/gUTOWK1ui0J+eaRzKxrIEzVvmCMxFd35lP/pA==
react@^0.0.0-424099da6:
version "0.0.0-424099da6"
resolved "https://registry.yarnpkg.com/react/-/react-0.0.0-424099da6.tgz#bf9155a10bb09783cfdc9e79438062af4b249861"
integrity sha512-z/brDYS4RaX3+zknH8nIV7i9B7We3hFBdD0QWhDKKgEHInFLF1Y/+2GsdedDI69GWw8Hv6mw1iilycHjHRaCZA==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
@@ -10658,10 +10658,10 @@ sax@>=0.6.0, sax@^1.2.4:
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
scheduler@0.0.0-50b50c26f, scheduler@^0.0.0-50b50c26f:
version "0.0.0-50b50c26f"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.0.0-50b50c26f.tgz#09bedde1c64d7a042b557bee2dbf5faf5fd58a50"
integrity sha512-LBN3zrP8iBdILOoYxybFtkU7j+ldZTHORKyYyVLwXuIwGQ8/Xhs5VZjNQ5R2Xru2zv3GGVpJSbd47EpDuD2EHw==
scheduler@0.0.0-424099da6, scheduler@^0.0.0-424099da6:
version "0.0.0-424099da6"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.0.0-424099da6.tgz#5311edfc2716479475517fdcbc24909948026bdb"
integrity sha512-eDsz8sdikcel1lKRDJhUZ17K22rOdmJAV07coMgIvdX0MoHh9cVNQ+iryU7O9Gi/c7ySE3DaX1M9xBqAqyXA3g==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"