From cb3fb4212972ad514ff53bff96530a0dffcaabcf Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 17 Jul 2019 11:12:39 -0700 Subject: [PATCH] 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. --- flow-typed/npm/react-test-renderer_v16.x.x.js | 2 +- package.json | 10 +- .../react-devtools-core/src/standalone.js | 14 +- shells/browser/shared/src/main.js | 17 +- shells/dev/src/devtools.js | 3 +- src/__tests__/console-test.js | 349 ++++++++++++++++ src/__tests__/inspectedElementContext-test.js | 101 +++-- src/__tests__/profilingCache-test.js | 28 +- src/__tests__/profilingCharts-test.js | 12 +- .../profilingCommitTreeBuilder-test.js | 4 +- src/__tests__/utils.js | 50 ++- src/backend/agent.js | 17 + src/backend/console.js | 125 ++++++ src/backend/describeComponentFrame.js | 41 ++ src/backend/index.js | 3 +- src/backend/renderer.js | 387 ++++++++++++------ src/backend/types.js | 4 + src/bridge.js | 1 + src/constants.js | 3 + .../views/Settings/GeneralSettings.js | 41 +- .../views/Settings/SettingsContext.js | 27 +- src/hook.js | 31 ++ src/utils.js | 22 +- yarn.lock | 46 +-- 24 files changed, 1083 insertions(+), 255 deletions(-) create mode 100644 src/__tests__/console-test.js create mode 100644 src/backend/console.js create mode 100644 src/backend/describeComponentFrame.js diff --git a/flow-typed/npm/react-test-renderer_v16.x.x.js b/flow-typed/npm/react-test-renderer_v16.x.x.js index 87a149a1d37d4..67eb20ab753fa 100644 --- a/flow-typed/npm/react-test-renderer_v16.x.x.js +++ b/flow-typed/npm/react-test-renderer_v16.x.x.js @@ -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' { diff --git a/package.json b/package.json index daf94f6447abb..18c5527081a3d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/react-devtools-core/src/standalone.js b/packages/react-devtools-core/src/standalone.js index d98956276e643..c105f20cc8449 100644 --- a/packages/react-devtools-core/src/standalone.js +++ b/packages/react-devtools-core/src/standalone.js @@ -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;' + diff --git a/shells/browser/shared/src/main.js b/shells/browser/shared/src/main.js index 3d80e951ec0b8..3554ed03eee1e 100644 --- a/shells/browser/shared/src/main.js +++ b/shells/browser/shared/src/main.js @@ -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(); } diff --git a/shells/dev/src/devtools.js b/shells/dev/src/devtools.js index 6bf66cddcdd49..80d8bb38ccb1c 100644 --- a/shells/dev/src/devtools.js +++ b/shells/dev/src/devtools.js @@ -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); diff --git a/src/__tests__/console-test.js b/src/__tests__/console-test.js new file mode 100644 index 0000000000000..92d54d2097aec --- /dev/null +++ b/src/__tests__/console-test.js @@ -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(, 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 = () => ( + + + + ); + const Child = () => { + fakeConsole.error('error'); + fakeConsole.log('log'); + fakeConsole.warn('warn'); + return null; + }; + + act(() => ReactDOM.render(, 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 = () => ( + + + + ); + 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(, 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 = () => ( + + + + ); + class Child extends React.Component { + 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(, container)); + act(() => ReactDOM.render(, 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 = () => ( + + + + ); + class Child extends React.Component { + state = {}; + static getDerivedStateFromProps() { + fakeConsole.error('error'); + fakeConsole.log('log'); + fakeConsole.warn('warn'); + return null; + } + render() { + return null; + } + } + + act(() => ReactDOM.render(, 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(, 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(, 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 **)' + ); + }); +}); diff --git a/src/__tests__/inspectedElementContext-test.js b/src/__tests__/inspectedElementContext-test.js index 7e9ee97dc628b..01895d183d9b8 100644 --- a/src/__tests__/inspectedElementContext-test.js +++ b/src/__tests__/inspectedElementContext-test.js @@ -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( - { + TestUtils.act(() => { + ReactDOM.render( + , - container - ); + }} + />, + 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'); diff --git a/src/__tests__/profilingCache-test.js b/src/__tests__/profilingCache-test.js index 4e1688a778f78..e5b56028936fd 100644 --- a/src/__tests__/profilingCache-test.js +++ b/src/__tests__/profilingCache-test.js @@ -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) => ); @@ -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) => ); @@ -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 ( @@ -314,11 +314,11 @@ describe('ProfilingCache', () => { ); }; const Parent = () => { - Scheduler.advanceTime(2); + Scheduler.unstable_advanceTime(2); return ; }; 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 ( }> @@ -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) => ); @@ -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) => ); @@ -508,7 +508,7 @@ describe('ProfilingCache', () => { ); }; const Child = ({ duration }) => { - Scheduler.advanceTime(duration); + Scheduler.unstable_advanceTime(duration); return null; }; const MemoizedChild = React.memo(Child); diff --git a/src/__tests__/profilingCharts-test.js b/src/__tests__/profilingCharts-test.js index 9cb6d25d6fd0b..dbf1ee16544b3 100644 --- a/src/__tests__/profilingCharts-test.js +++ b/src/__tests__/profilingCharts-test.js @@ -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 ( @@ -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 ( @@ -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 ( @@ -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; }); diff --git a/src/__tests__/profilingCommitTreeBuilder-test.js b/src/__tests__/profilingCommitTreeBuilder-test.js index 68d42961ea361..0ef670607f967 100644 --- a/src/__tests__/profilingCommitTreeBuilder-test.js +++ b/src/__tests__/profilingCommitTreeBuilder-test.js @@ -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) => ); }; const Child = React.memo(function Child() { - Scheduler.advanceTime(2); + Scheduler.unstable_advanceTime(2); return null; }); diff --git a/src/__tests__/utils.js b/src/__tests__/utils.js index 089ddf045fed1..345b773832aaf 100644 --- a/src/__tests__/utils.js +++ b/src/__tests__/utils.js @@ -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 { - 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 { diff --git a/src/backend/agent.js b/src/backend/agent.js index abdd5ed37f6f1..fadd2e697ee55 100644 --- a/src/backend/agent.js +++ b/src/backend/agent.js @@ -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) => { for (let rendererID in this._rendererInterfaces) { const renderer = ((this._rendererInterfaces[ diff --git a/src/backend/console.js b/src/backend/console.js new file mode 100644 index 0000000000000..95a457781972c --- /dev/null +++ b/src/backend/console.js @@ -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; + } +} diff --git a/src/backend/describeComponentFrame.js b/src/backend/describeComponentFrame.js new file mode 100644 index 0000000000000..5c632471babc1 --- /dev/null +++ b/src/backend/describeComponentFrame.js @@ -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; +} diff --git a/src/backend/index.js b/src/backend/index.js index 3e65e4c00cffd..feee4c00dedd8 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -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, diff --git a/src/backend/renderer.js b/src/backend/renderer.js index dbb91eec68cdd..dadee18bc61e8 100644 --- a/src/backend/renderer.js +++ b/src/backend/renderer.js @@ -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, diff --git a/src/backend/types.js b/src/backend/types.js index 59791db472ae7..ac6b6a49da846 100644 --- a/src/backend/types.js +++ b/src/backend/types.js @@ -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, }; diff --git a/src/bridge.js b/src/bridge.js index d9db3b6318f10..c5ed0f4a0379c 100644 --- a/src/bridge.js +++ b/src/bridge.js @@ -98,6 +98,7 @@ export default class Bridge extends EventEmitter<{| stopProfiling: [], syncSelectionFromNativeElementsPanel: [], syncSelectionToNativeElementsPanel: [], + updateAppendComponentStack: [boolean], updateComponentFilters: [Array], viewElementSource: [ElementAndRendererID], diff --git a/src/constants.js b/src/constants.js index 9267c53e4b5e1..da55631a01e45 100644 --- a/src/constants.js +++ b/src/constants.js @@ -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; diff --git a/src/devtools/views/Settings/GeneralSettings.js b/src/devtools/views/Settings/GeneralSettings.js index 899eaafefaea7..60d61bb817fa5 100644 --- a/src/devtools/views/Settings/GeneralSettings.js +++ b/src/devtools/views/Settings/GeneralSettings.js @@ -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 (
@@ -45,6 +43,17 @@ export default function GeneralSettings(_: {||}) {
+ +
+ +
); } diff --git a/src/devtools/views/Settings/SettingsContext.js b/src/devtools/views/Settings/SettingsContext.js index 70ad39f6134a2..4286e3d65df45 100644 --- a/src/devtools/views/Settings/SettingsContext.js +++ b/src/devtools/views/Settings/SettingsContext.js @@ -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( 'React::DevTools::displayDensity', 'compact' @@ -48,6 +61,10 @@ function SettingsContextController({ 'React::DevTools::theme', 'auto' ); + const [ + appendComponentStack, + setAppendComponentStack, + ] = useLocalStorage(LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY, true); const documentElements = useMemo(() => { const array: Array = [ @@ -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, ] ); diff --git a/src/hook.js b/src/hook.js index 168aa3591a430..3e9e55d035b07 100644 --- a/src/hook.js +++ b/src/hook.js @@ -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__; diff --git a/src/utils.js b/src/utils.js index 3f73a79671736..f655c7729a01e 100644 --- a/src/utils.js +++ b/src/utils.js @@ -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 diff --git a/yarn.lock b/yarn.lock index 80d36901ff0a8..616e6e2645205 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"