Expose DEV-mode warnings in devtools UI (#20463)

Co-authored-by: Brian Vaughn <bvaughn@fb.com>
This commit is contained in:
Sebastian Silbermann
2020-12-22 17:09:29 +01:00
committed by GitHub
parent 6cbb9394d1
commit 09a2c363a5
50 changed files with 4613 additions and 1794 deletions
+4
View File
@@ -20,6 +20,7 @@ import {
getAppendComponentStack,
getBreakOnConsoleErrors,
getSavedComponentFilters,
getShowInlineWarningsAndErrors,
} from 'react-devtools-shared/src/utils';
import {Server} from 'ws';
import {join} from 'path';
@@ -303,6 +304,9 @@ function startServer(
)};
window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = ${JSON.stringify(
getSavedComponentFilters(),
)};
window.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ = ${JSON.stringify(
getShowInlineWarningsAndErrors(),
)};`;
response.end(
+7 -6
View File
@@ -10,6 +10,7 @@ import {
getAppendComponentStack,
getBreakOnConsoleErrors,
getSavedComponentFilters,
getShowInlineWarningsAndErrors,
} from 'react-devtools-shared/src/utils';
import {
localStorageGetItem,
@@ -29,18 +30,18 @@ let panelCreated = false;
// because they are stored in localStorage within the context of the extension.
// Instead it relies on the extension to pass filters through.
function syncSavedPreferences() {
const appendComponentStack = getAppendComponentStack();
const breakOnConsoleErrors = getBreakOnConsoleErrors();
const componentFilters = getSavedComponentFilters();
chrome.devtools.inspectedWindow.eval(
`window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ = ${JSON.stringify(
appendComponentStack,
getAppendComponentStack(),
)};
window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ = ${JSON.stringify(
breakOnConsoleErrors,
getBreakOnConsoleErrors(),
)};
window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = ${JSON.stringify(
componentFilters,
getSavedComponentFilters(),
)};
window.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ = ${JSON.stringify(
getShowInlineWarningsAndErrors(),
)};`,
);
}
+3
View File
@@ -24,11 +24,13 @@ function startActivation(contentWindow: window) {
appendComponentStack,
breakOnConsoleErrors,
componentFilters,
showInlineWarningsAndErrors,
} = data;
contentWindow.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ = appendComponentStack;
contentWindow.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ = breakOnConsoleErrors;
contentWindow.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = componentFilters;
contentWindow.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ = showInlineWarningsAndErrors;
// TRICKY
// The backend entry point may be required in the context of an iframe or the parent window.
@@ -40,6 +42,7 @@ function startActivation(contentWindow: window) {
window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ = appendComponentStack;
window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ = breakOnConsoleErrors;
window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = componentFilters;
window.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ = showInlineWarningsAndErrors;
}
finishActivation(contentWindow);
+2
View File
@@ -9,6 +9,7 @@ import {
getAppendComponentStack,
getBreakOnConsoleErrors,
getSavedComponentFilters,
getShowInlineWarningsAndErrors,
} from 'react-devtools-shared/src/utils';
import {
MESSAGE_TYPE_GET_SAVED_PREFERENCES,
@@ -41,6 +42,7 @@ export function initialize(
appendComponentStack: getAppendComponentStack(),
breakOnConsoleErrors: getBreakOnConsoleErrors(),
componentFilters: getSavedComponentFilters(),
showInlineWarningsAndErrors: getShowInlineWarningsAndErrors(),
},
'*',
);
@@ -12,19 +12,19 @@ Array [
Object {
"displayName": "Grandparent",
"hocDisplayNames": null,
"id": 7,
"id": 2,
"type": 5,
},
Object {
"displayName": "Parent",
"hocDisplayNames": null,
"id": 9,
"id": 3,
"type": 5,
},
Object {
"displayName": "Child",
"hocDisplayNames": null,
"id": 8,
"id": 4,
"type": 5,
},
]
@@ -115,7 +115,7 @@ Array [
Object {
"displayName": "Grandparent",
"hocDisplayNames": null,
"id": 5,
"id": 2,
"type": 5,
},
]
@@ -1,140 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Store component filters should filter HOCs: 1: mount 1`] = `
[root]
▾ <Component> [Bar][Foo]
▾ <Component> [Foo]
▾ <Component>
<div>
`;
exports[`Store component filters should filter HOCs: 2: hide all HOCs 1`] = `
[root]
▾ <Component>
<div>
`;
exports[`Store component filters should filter HOCs: 3: disable HOC filter 1`] = `
[root]
▾ <Component> [Bar][Foo]
▾ <Component> [Foo]
▾ <Component>
<div>
`;
exports[`Store component filters should filter by display name: 1: mount 1`] = `
[root]
▾ <Foo>
<Text>
▾ <Bar>
<Text>
▾ <Baz>
<Text>
`;
exports[`Store component filters should filter by display name: 2: filter "Foo" 1`] = `
[root]
<Text>
▾ <Bar>
<Text>
▾ <Baz>
<Text>
`;
exports[`Store component filters should filter by display name: 3: filter "Ba" 1`] = `
[root]
▾ <Foo>
<Text>
<Text>
<Text>
`;
exports[`Store component filters should filter by display name: 4: filter "B.z" 1`] = `
[root]
▾ <Foo>
<Text>
▾ <Bar>
<Text>
<Text>
`;
exports[`Store component filters should filter by path: 1: mount 1`] = `
[root]
▾ <Component>
<div>
`;
exports[`Store component filters should filter by path: 2: hide all components declared within this test filed 1`] = `[root]`;
exports[`Store component filters should filter by path: 3: hide components in a made up fake path 1`] = `
[root]
▾ <Component>
<div>
`;
exports[`Store component filters should ignore invalid ElementTypeRoot filter: 1: mount 1`] = `
[root]
▾ <Root>
<div>
`;
exports[`Store component filters should ignore invalid ElementTypeRoot filter: 2: add invalid filter 1`] = `
[root]
▾ <Root>
<div>
`;
exports[`Store component filters should not break when Suspense nodes are filtered from the tree: 1: suspended 1`] = `
[root]
▾ <Wrapper>
▾ <Loading>
<div>
`;
exports[`Store component filters should not break when Suspense nodes are filtered from the tree: 2: resolved 1`] = `
[root]
▾ <Wrapper>
<Component>
`;
exports[`Store component filters should not break when Suspense nodes are filtered from the tree: 3: suspended 1`] = `
[root]
▾ <Wrapper>
▾ <Loading>
<div>
`;
exports[`Store component filters should support filtering by element type: 1: mount 1`] = `
[root]
▾ <Root>
▾ <div>
▾ <Component>
<div>
`;
exports[`Store component filters should support filtering by element type: 2: hide host components 1`] = `
[root]
▾ <Root>
<Component>
`;
exports[`Store component filters should support filtering by element type: 3: hide class components 1`] = `
[root]
▾ <div>
▾ <Component>
<div>
`;
exports[`Store component filters should support filtering by element type: 4: hide class and function components 1`] = `
[root]
▾ <div>
<div>
`;
exports[`Store component filters should support filtering by element type: 5: disable all filters 1`] = `
[root]
▾ <Root>
▾ <div>
▾ <Component>
<div>
`;
@@ -15,6 +15,7 @@ import type {
} from 'react-devtools-shared/src/devtools/views/Components/InspectedElementContext';
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
import type Store from 'react-devtools-shared/src/devtools/store';
import {withErrorsOrWarningsIgnored} from 'react-devtools-shared/src/__tests__/utils';
describe('InspectedElementContext', () => {
let React;
@@ -66,6 +67,10 @@ describe('InspectedElementContext', () => {
.TreeContextController;
});
afterEach(() => {
jest.restoreAllMocks();
});
const Contexts = ({
children,
defaultSelectedElementID = null,
@@ -386,10 +391,10 @@ describe('InspectedElementContext', () => {
it('should temporarily disable console logging when re-running a component to inspect its hooks', async done => {
let targetRenderCount = 0;
const errorSpy = ((console: any).error = jest.fn());
const infoSpy = ((console: any).info = jest.fn());
const logSpy = ((console: any).log = jest.fn());
const warnSpy = ((console: any).warn = jest.fn());
jest.spyOn(console, 'error').mockImplementation(() => {});
jest.spyOn(console, 'info').mockImplementation(() => {});
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
const Target = React.memo(props => {
targetRenderCount++;
@@ -407,14 +412,14 @@ describe('InspectedElementContext', () => {
);
expect(targetRenderCount).toBe(1);
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalledWith('error');
expect(infoSpy).toHaveBeenCalledTimes(1);
expect(infoSpy).toHaveBeenCalledWith('info');
expect(logSpy).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledWith('log');
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith('warn');
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith('error');
expect(console.info).toHaveBeenCalledTimes(1);
expect(console.info).toHaveBeenCalledWith('info');
expect(console.log).toHaveBeenCalledTimes(1);
expect(console.log).toHaveBeenCalledWith('log');
expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledWith('warn');
const id = ((store.getElementIDAtIndex(0): any): number);
@@ -442,10 +447,10 @@ describe('InspectedElementContext', () => {
expect(inspectedElement).not.toBe(null);
expect(targetRenderCount).toBe(2);
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(infoSpy).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.info).toHaveBeenCalledTimes(1);
expect(console.log).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledTimes(1);
done();
});
@@ -1569,21 +1574,20 @@ describe('InspectedElementContext', () => {
);
expect(storeAsGlobal).not.toBeNull();
const logSpy = jest.fn();
spyOn(console, 'log').and.callFake(logSpy);
jest.spyOn(console, 'log').mockImplementation(() => {});
// Should store the whole value (not just the hydrated parts)
storeAsGlobal(id, ['props', 'nestedObject']);
jest.runOnlyPendingTimers();
expect(logSpy).toHaveBeenCalledWith('$reactTemp1');
expect(console.log).toHaveBeenCalledWith('$reactTemp1');
expect(global.$reactTemp1).toBe(nestedObject);
logSpy.mockReset();
console.log.mockReset();
// Should store the nested property specified (not just the outer value)
storeAsGlobal(id, ['props', 'nestedObject', 'a', 'b']);
jest.runOnlyPendingTimers();
expect(logSpy).toHaveBeenCalledWith('$reactTemp2');
expect(console.log).toHaveBeenCalledWith('$reactTemp2');
expect(global.$reactTemp2).toBe(nestedObject.a.b);
done();
@@ -1805,4 +1809,421 @@ describe('InspectedElementContext', () => {
done();
});
describe('inline errors and warnings', () => {
// Some actions require the Fiber id.
// In those instances you might want to make assertions based on the ID instead of the index.
function getErrorsAndWarningsForElement(id: number) {
const index = ((store.getIndexOfElementID(id): any): number);
return getErrorsAndWarningsForElementAtIndex(index);
}
async function getErrorsAndWarningsForElementAtIndex(index) {
const id = ((store.getElementIDAtIndex(index): any): number);
let errors = null;
let warnings = null;
function Suspender({target}) {
const {getInspectedElement} = React.useContext(InspectedElementContext);
const inspectedElement = getInspectedElement(id);
errors = inspectedElement.errors;
warnings = inspectedElement.warnings;
return null;
}
let root;
await utils.actAsync(() => {
root = TestRenderer.create(
<Contexts
defaultSelectedElementID={id}
defaultSelectedElementIndex={index}>
<React.Suspense fallback={null}>
<Suspender target={id} />
</React.Suspense>
</Contexts>,
);
}, false);
await utils.actAsync(() => {
root.unmount();
}, false);
return {errors, warnings};
}
it('during render get recorded', async () => {
const Example = () => {
console.error('test-only: render error');
console.warn('test-only: render warning');
return null;
};
const container = document.createElement('div');
await withErrorsOrWarningsIgnored(['test-only: '], async () => {
await utils.actAsync(() =>
ReactDOM.render(<Example repeatWarningCount={1} />, container),
);
});
const data = await getErrorsAndWarningsForElementAtIndex(0);
expect(data).toMatchInlineSnapshot(`
Object {
"errors": Array [
Array [
"test-only: render error",
1,
],
],
"warnings": Array [
Array [
"test-only: render warning",
1,
],
],
}
`);
});
it('during render get deduped', async () => {
const Example = () => {
console.error('test-only: render error');
console.error('test-only: render error');
console.warn('test-only: render warning');
console.warn('test-only: render warning');
console.warn('test-only: render warning');
return null;
};
const container = document.createElement('div');
await utils.withErrorsOrWarningsIgnored(['test-only:'], async () => {
await utils.actAsync(() =>
ReactDOM.render(<Example repeatWarningCount={1} />, container),
);
});
const data = await getErrorsAndWarningsForElementAtIndex(0);
expect(data).toMatchInlineSnapshot(`
Object {
"errors": Array [
Array [
"test-only: render error",
2,
],
],
"warnings": Array [
Array [
"test-only: render warning",
3,
],
],
}
`);
});
it('during layout (mount) get recorded', async () => {
const Example = () => {
// Note we only test mount because once the component unmounts,
// it is no longer in the store and warnings are ignored.
React.useLayoutEffect(() => {
console.error('test-only: useLayoutEffect error');
console.warn('test-only: useLayoutEffect warning');
}, []);
return null;
};
const container = document.createElement('div');
await utils.withErrorsOrWarningsIgnored(['test-only:'], async () => {
await utils.actAsync(() =>
ReactDOM.render(<Example repeatWarningCount={1} />, container),
);
});
const data = await getErrorsAndWarningsForElementAtIndex(0);
expect(data).toMatchInlineSnapshot(`
Object {
"errors": Array [
Array [
"test-only: useLayoutEffect error",
1,
],
],
"warnings": Array [
Array [
"test-only: useLayoutEffect warning",
1,
],
],
}
`);
});
it('during passive (mount) get recorded', async () => {
const Example = () => {
// Note we only test mount because once the component unmounts,
// it is no longer in the store and warnings are ignored.
React.useEffect(() => {
console.error('test-only: useEffect error');
console.warn('test-only: useEffect warning');
}, []);
return null;
};
const container = document.createElement('div');
await utils.withErrorsOrWarningsIgnored(['test-only:'], async () => {
await utils.actAsync(() =>
ReactDOM.render(<Example repeatWarningCount={1} />, container),
);
});
const data = await getErrorsAndWarningsForElementAtIndex(0);
expect(data).toMatchInlineSnapshot(`
Object {
"errors": Array [
Array [
"test-only: useEffect error",
1,
],
],
"warnings": Array [
Array [
"test-only: useEffect warning",
1,
],
],
}
`);
});
it('from react get recorded without a component stack', async () => {
const Example = () => {
return [<div />];
};
const container = document.createElement('div');
await utils.withErrorsOrWarningsIgnored(
['Warning: Each child in a list should have a unique "key" prop.'],
async () => {
await utils.actAsync(() =>
ReactDOM.render(<Example repeatWarningCount={1} />, container),
);
},
);
const data = await getErrorsAndWarningsForElementAtIndex(0);
expect(data).toMatchInlineSnapshot(`
Object {
"errors": Array [
Array [
"Warning: Each child in a list should have a unique \\"key\\" prop. See https://reactjs.org/link/warning-keys for more information.",
1,
],
],
"warnings": Array [],
}
`);
});
it('can be cleared for the whole app', async () => {
const Example = () => {
console.error('test-only: render error');
console.warn('test-only: render warning');
return null;
};
const container = document.createElement('div');
await utils.withErrorsOrWarningsIgnored(['test-only:'], async () => {
await utils.actAsync(() =>
ReactDOM.render(<Example repeatWarningCount={1} />, container),
);
});
store.clearErrorsAndWarnings();
// Flush events to the renderer.
jest.runOnlyPendingTimers();
const data = await getErrorsAndWarningsForElementAtIndex(0);
expect(data).toMatchInlineSnapshot(`
Object {
"errors": Array [],
"warnings": Array [],
}
`);
});
it('can be cleared for a particular Fiber (only errors)', async () => {
const Example = ({id}) => {
console.error(`test-only: render error #${id}`);
console.warn(`test-only: render warning #${id}`);
return null;
};
const container = document.createElement('div');
await utils.withErrorsOrWarningsIgnored(['test-only:'], async () => {
await utils.actAsync(() =>
ReactDOM.render(
<React.Fragment>
<Example id={1} />
<Example id={2} />
</React.Fragment>,
container,
),
);
});
store.clearWarningsForElement(2);
// Flush events to the renderer.
jest.runOnlyPendingTimers();
let data = [
await getErrorsAndWarningsForElement(1),
await getErrorsAndWarningsForElement(2),
];
expect(data).toMatchInlineSnapshot(`
Array [
Object {
"errors": Array [
Array [
"test-only: render error #1",
1,
],
],
"warnings": Array [
Array [
"test-only: render warning #1",
1,
],
],
},
Object {
"errors": Array [
Array [
"test-only: render error #2",
1,
],
],
"warnings": Array [],
},
]
`);
store.clearWarningsForElement(1);
// Flush events to the renderer.
jest.runOnlyPendingTimers();
data = [
await getErrorsAndWarningsForElement(1),
await getErrorsAndWarningsForElement(2),
];
expect(data).toMatchInlineSnapshot(`
Array [
Object {
"errors": Array [
Array [
"test-only: render error #1",
1,
],
],
"warnings": Array [],
},
Object {
"errors": Array [
Array [
"test-only: render error #2",
1,
],
],
"warnings": Array [],
},
]
`);
});
it('can be cleared for a particular Fiber (only warnings)', async () => {
const Example = ({id}) => {
console.error(`test-only: render error #${id}`);
console.warn(`test-only: render warning #${id}`);
return null;
};
const container = document.createElement('div');
await utils.withErrorsOrWarningsIgnored(['test-only:'], async () => {
await utils.actAsync(() =>
ReactDOM.render(
<React.Fragment>
<Example id={1} />
<Example id={2} />
</React.Fragment>,
container,
),
);
});
store.clearErrorsForElement(2);
// Flush events to the renderer.
jest.runOnlyPendingTimers();
let data = [
await getErrorsAndWarningsForElement(1),
await getErrorsAndWarningsForElement(2),
];
expect(data).toMatchInlineSnapshot(`
Array [
Object {
"errors": Array [
Array [
"test-only: render error #1",
1,
],
],
"warnings": Array [
Array [
"test-only: render warning #1",
1,
],
],
},
Object {
"errors": Array [],
"warnings": Array [
Array [
"test-only: render warning #2",
1,
],
],
},
]
`);
store.clearErrorsForElement(1);
// Flush events to the renderer.
jest.runOnlyPendingTimers();
data = [
await getErrorsAndWarningsForElement(1),
await getErrorsAndWarningsForElement(2),
];
expect(data).toMatchInlineSnapshot(`
Array [
Object {
"errors": Array [],
"warnings": Array [
Array [
"test-only: render warning #1",
1,
],
],
},
Object {
"errors": Array [],
"warnings": Array [
Array [
"test-only: render warning #2",
1,
],
],
},
]
`);
});
});
});
@@ -120,4 +120,19 @@ describe('ProfilerStore', () => {
expect(data.commitData).toHaveLength(1);
expect(data.operations).toHaveLength(1);
});
it('should throw if component filters are modified while profiling', () => {
utils.act(() => store.profilerStore.startProfiling());
expect(() => {
utils.act(() => {
const {
ElementTypeHostComponent,
} = require('react-devtools-shared/src/types');
store.componentFilters = [
utils.createElementTypeFilter(ElementTypeHostComponent),
];
});
}).toThrow('Cannot modify filter preferences while profiling');
});
});
@@ -33,11 +33,24 @@ env.beforeEach(() => {
const {
getDefaultComponentFilters,
saveComponentFilters,
setShowInlineWarningsAndErrors,
} = require('react-devtools-shared/src/utils');
// Fake timers let us flush Bridge operations between setup and assertions.
jest.useFakeTimers();
// Use utils.js#withErrorsOrWarningsIgnored instead of directly mutating this array.
global._ignoredErrorOrWarningMessages = [];
function shouldIgnoreConsoleErrorOrWarn(args) {
const firstArg = args[0];
if (typeof firstArg !== 'string') {
return false;
}
return global._ignoredErrorOrWarningMessages.some(errorOrWarningMessage => {
return firstArg.indexOf(errorOrWarningMessage) !== -1;
});
}
const originalConsoleError = console.error;
// $FlowFixMe
console.error = (...args) => {
@@ -54,14 +67,32 @@ env.beforeEach(() => {
// DevTools intentionally wraps updates with acts from both DOM and test-renderer,
// since test updates are expected to impact both renderers.
return;
} else if (shouldIgnoreConsoleErrorOrWarn(args)) {
// Allows testing how DevTools behaves when it encounters console.error without cluttering the test output.
// Errors can be ignored by running in a special context provided by utils.js#withErrorsOrWarningsIgnored
return;
}
originalConsoleError.apply(console, args);
};
const originalConsoleWarn = console.warn;
// $FlowFixMe
console.warn = (...args) => {
if (shouldIgnoreConsoleErrorOrWarn(args)) {
// Allows testing how DevTools behaves when it encounters console.warn without cluttering the test output.
// Warnings can be ignored by running in a special context provided by utils.js#withErrorsOrWarningsIgnored
return;
}
originalConsoleWarn.apply(console, args);
};
// Initialize filters to a known good state.
saveComponentFilters(getDefaultComponentFilters());
global.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = getDefaultComponentFilters();
// Also initialize inline warnings so that we can test them.
setShowInlineWarningsAndErrors(true);
global.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ = true;
installHook(global);
const bridgeListeners = [];
@@ -14,6 +14,7 @@ describe('Store', () => {
let act;
let getRendererID;
let store;
let withErrorsOrWarningsIgnored;
beforeEach(() => {
agent = global.agent;
@@ -25,6 +26,7 @@ describe('Store', () => {
const utils = require('./utils');
act = utils.act;
getRendererID = utils.getRendererID;
withErrorsOrWarningsIgnored = utils.withErrorsOrWarningsIgnored;
});
it('should not allow a root node to be collapsed', () => {
@@ -1008,4 +1010,316 @@ describe('Store', () => {
done();
});
});
describe('inline errors and warnings', () => {
it('during render are counted', () => {
function Example() {
console.error('test-only: render error');
console.warn('test-only: render warning');
return null;
}
const container = document.createElement('div');
withErrorsOrWarningsIgnored(['test-only:'], () => {
act(() => ReactDOM.render(<Example />, container));
});
expect(store).toMatchInlineSnapshot(`
✕ 1, ⚠ 1
[root]
<Example> ✕⚠
`);
withErrorsOrWarningsIgnored(['test-only:'], () => {
act(() => ReactDOM.render(<Example rerender={1} />, container));
});
expect(store).toMatchInlineSnapshot(`
✕ 2, ⚠ 2
[root]
<Example> ✕⚠
`);
});
it('during layout get counted', () => {
function Example() {
React.useLayoutEffect(() => {
console.error('test-only: layout error');
console.warn('test-only: layout warning');
});
return null;
}
const container = document.createElement('div');
withErrorsOrWarningsIgnored(['test-only:'], () => {
act(() => ReactDOM.render(<Example />, container));
});
expect(store).toMatchInlineSnapshot(`
✕ 1, ⚠ 1
[root]
<Example> ✕⚠
`);
withErrorsOrWarningsIgnored(['test-only:'], () => {
act(() => ReactDOM.render(<Example rerender={1} />, container));
});
expect(store).toMatchInlineSnapshot(`
✕ 2, ⚠ 2
[root]
<Example> ✕⚠
`);
});
// This is not great, but it seems safer than potentially flushing between commits.
// Our logic for determining how to handle e.g. suspended trees or error boundaries
// is built on the assumption that we're evaluating the results of a commit, not an in-progress render.
it('during passive get counted (but not until the next commit)', () => {
function Example() {
React.useEffect(() => {
console.error('test-only: passive error');
console.warn('test-only: passive warning');
});
return null;
}
const container = document.createElement('div');
withErrorsOrWarningsIgnored(['test-only:'], () => {
act(() => ReactDOM.render(<Example />, container));
});
expect(store).toMatchInlineSnapshot(`
[root]
<Example>
`);
withErrorsOrWarningsIgnored(['test-only:'], () => {
act(() => ReactDOM.render(<Example rerender={1} />, container));
});
expect(store).toMatchInlineSnapshot(`
✕ 1, ⚠ 1
[root]
<Example> ✕⚠
`);
act(() => ReactDOM.unmountComponentAtNode(container));
expect(store).toMatchInlineSnapshot(``);
});
it('from react get counted', () => {
const container = document.createElement('div');
function Example() {
return [<Child />];
}
function Child() {
return null;
}
withErrorsOrWarningsIgnored(
['Warning: Each child in a list should have a unique "key" prop'],
() => {
act(() => ReactDOM.render(<Example />, container));
},
);
expect(store).toMatchInlineSnapshot(`
✕ 1, ⚠ 0
[root]
▾ <Example> ✕
<Child>
`);
});
it('can be cleared for the whole app', () => {
function Example() {
console.error('test-only: render error');
console.warn('test-only: render warning');
return null;
}
const container = document.createElement('div');
withErrorsOrWarningsIgnored(['test-only:'], () => {
act(() =>
ReactDOM.render(
<React.Fragment>
<Example />
<Example />
</React.Fragment>,
container,
),
);
});
expect(store).toMatchInlineSnapshot(`
✕ 2, ⚠ 2
[root]
<Example> ✕⚠
<Example> ✕⚠
`);
store.clearErrorsAndWarnings();
// flush events to the renderer
jest.runAllTimers();
expect(store).toMatchInlineSnapshot(`
[root]
<Example>
<Example>
`);
});
it('can be cleared for particular Fiber (only warnings)', () => {
function Example() {
console.error('test-only: render error');
console.warn('test-only: render warning');
return null;
}
const container = document.createElement('div');
withErrorsOrWarningsIgnored(['test-only:'], () => {
act(() =>
ReactDOM.render(
<React.Fragment>
<Example />
<Example />
</React.Fragment>,
container,
),
);
});
expect(store).toMatchInlineSnapshot(`
✕ 2, ⚠ 2
[root]
<Example> ✕⚠
<Example> ✕⚠
`);
store.clearWarningsForElement(2);
// Flush events to the renderer.
jest.runAllTimers();
expect(store).toMatchInlineSnapshot(`
✕ 2, ⚠ 1
[root]
<Example> ✕⚠
<Example> ✕
`);
});
it('can be cleared for a particular Fiber (only errors)', () => {
function Example() {
console.error('test-only: render error');
console.warn('test-only: render warning');
return null;
}
const container = document.createElement('div');
withErrorsOrWarningsIgnored(['test-only:'], () => {
act(() =>
ReactDOM.render(
<React.Fragment>
<Example />
<Example />
</React.Fragment>,
container,
),
);
});
expect(store).toMatchInlineSnapshot(`
✕ 2, ⚠ 2
[root]
<Example> ✕⚠
<Example> ✕⚠
`);
store.clearErrorsForElement(2);
// Flush events to the renderer.
jest.runAllTimers();
expect(store).toMatchInlineSnapshot(`
✕ 1, ⚠ 2
[root]
<Example> ✕⚠
<Example> ⚠
`);
});
it('are updated when fibers are removed from the tree', () => {
function ComponentWithWarning() {
console.warn('test-only: render warning');
return null;
}
function ComponentWithError() {
console.error('test-only: render error');
return null;
}
function ComponentWithWarningAndError() {
console.error('test-only: render error');
console.warn('test-only: render warning');
return null;
}
const container = document.createElement('div');
withErrorsOrWarningsIgnored(['test-only:'], () => {
act(() =>
ReactDOM.render(
<React.Fragment>
<ComponentWithError />
<ComponentWithWarning />
<ComponentWithWarningAndError />
</React.Fragment>,
container,
),
);
});
expect(store).toMatchInlineSnapshot(`
✕ 2, ⚠ 2
[root]
<ComponentWithError> ✕
<ComponentWithWarning> ⚠
<ComponentWithWarningAndError> ✕⚠
`);
withErrorsOrWarningsIgnored(['test-only:'], () => {
act(() =>
ReactDOM.render(
<React.Fragment>
<ComponentWithWarning />
<ComponentWithWarningAndError />
</React.Fragment>,
container,
),
);
});
expect(store).toMatchInlineSnapshot(`
✕ 1, ⚠ 2
[root]
<ComponentWithWarning> ⚠
<ComponentWithWarningAndError> ✕⚠
`);
withErrorsOrWarningsIgnored(['test-only:'], () => {
act(() =>
ReactDOM.render(
<React.Fragment>
<ComponentWithWarning />
</React.Fragment>,
container,
),
);
});
expect(store).toMatchInlineSnapshot(`
✕ 0, ⚠ 2
[root]
<ComponentWithWarning> ⚠
`);
withErrorsOrWarningsIgnored(['test-only:'], () => {
act(() => ReactDOM.render(<React.Fragment />, container));
});
expect(store).toMatchInlineSnapshot(`[root]`);
expect(store.errorCount).toBe(0);
expect(store.warningCount).toBe(0);
});
});
});
@@ -48,22 +48,28 @@ describe('Store component filters', () => {
});
it('should support filtering by element type', () => {
class Root extends React.Component<{|children: React$Node|}> {
class ClassComponent extends React.Component<{|children: React$Node|}> {
render() {
return <div>{this.props.children}</div>;
}
}
const Component = () => <div>Hi</div>;
const FunctionComponent = () => <div>Hi</div>;
act(() =>
ReactDOM.render(
<Root>
<Component />
</Root>,
<ClassComponent>
<FunctionComponent />
</ClassComponent>,
document.createElement('div'),
),
);
expect(store).toMatchSnapshot('1: mount');
expect(store).toMatchInlineSnapshot(`
[root]
▾ <ClassComponent>
▾ <div>
▾ <FunctionComponent>
<div>
`);
act(
() =>
@@ -71,8 +77,11 @@ describe('Store component filters', () => {
utils.createElementTypeFilter(Types.ElementTypeHostComponent),
]),
);
expect(store).toMatchSnapshot('2: hide host components');
expect(store).toMatchInlineSnapshot(`
[root]
▾ <ClassComponent>
<FunctionComponent>
`);
act(
() =>
@@ -80,8 +89,12 @@ describe('Store component filters', () => {
utils.createElementTypeFilter(Types.ElementTypeClass),
]),
);
expect(store).toMatchSnapshot('3: hide class components');
expect(store).toMatchInlineSnapshot(`
[root]
▾ <div>
▾ <FunctionComponent>
<div>
`);
act(
() =>
@@ -90,8 +103,11 @@ describe('Store component filters', () => {
utils.createElementTypeFilter(Types.ElementTypeFunction),
]),
);
expect(store).toMatchSnapshot('4: hide class and function components');
expect(store).toMatchInlineSnapshot(`
[root]
▾ <div>
<div>
`);
act(
() =>
@@ -100,15 +116,33 @@ describe('Store component filters', () => {
utils.createElementTypeFilter(Types.ElementTypeFunction, false),
]),
);
expect(store).toMatchInlineSnapshot(`
[root]
▾ <ClassComponent>
▾ <div>
▾ <FunctionComponent>
<div>
`);
expect(store).toMatchSnapshot('5: disable all filters');
act(() => (store.componentFilters = []));
expect(store).toMatchInlineSnapshot(`
[root]
▾ <ClassComponent>
▾ <div>
▾ <FunctionComponent>
<div>
`);
});
it('should ignore invalid ElementTypeRoot filter', () => {
const Root = () => <div>Hi</div>;
const Component = () => <div>Hi</div>;
act(() => ReactDOM.render(<Root />, document.createElement('div')));
expect(store).toMatchSnapshot('1: mount');
act(() => ReactDOM.render(<Component />, document.createElement('div')));
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Component>
<div>
`);
act(
() =>
@@ -117,7 +151,11 @@ describe('Store component filters', () => {
]),
);
expect(store).toMatchSnapshot('2: add invalid filter');
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Component>
<div>
`);
});
it('should filter by display name', () => {
@@ -136,27 +174,59 @@ describe('Store component filters', () => {
document.createElement('div'),
),
);
expect(store).toMatchSnapshot('1: mount');
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Foo>
<Text>
▾ <Bar>
<Text>
▾ <Baz>
<Text>
`);
act(
() => (store.componentFilters = [utils.createDisplayNameFilter('Foo')]),
);
expect(store).toMatchSnapshot('2: filter "Foo"');
expect(store).toMatchInlineSnapshot(`
[root]
<Text>
▾ <Bar>
<Text>
▾ <Baz>
<Text>
`);
act(() => (store.componentFilters = [utils.createDisplayNameFilter('Ba')]));
expect(store).toMatchSnapshot('3: filter "Ba"');
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Foo>
<Text>
<Text>
<Text>
`);
act(
() => (store.componentFilters = [utils.createDisplayNameFilter('B.z')]),
);
expect(store).toMatchSnapshot('4: filter "B.z"');
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Foo>
<Text>
▾ <Bar>
<Text>
<Text>
`);
});
it('should filter by path', () => {
const Component = () => <div>Hi</div>;
act(() => ReactDOM.render(<Component />, document.createElement('div')));
expect(store).toMatchSnapshot('1: mount');
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Component>
<div>
`);
act(
() =>
@@ -165,9 +235,7 @@ describe('Store component filters', () => {
]),
);
expect(store).toMatchSnapshot(
'2: hide all components declared within this test filed',
);
expect(store).toMatchInlineSnapshot(`[root]`);
act(
() =>
@@ -176,7 +244,11 @@ describe('Store component filters', () => {
]),
);
expect(store).toMatchSnapshot('3: hide components in a made up fake path');
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Component>
<div>
`);
});
it('should filter HOCs', () => {
@@ -187,15 +259,29 @@ describe('Store component filters', () => {
Bar.displayName = 'Bar(Foo(Component))';
act(() => ReactDOM.render(<Bar />, document.createElement('div')));
expect(store).toMatchSnapshot('1: mount');
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Component> [Bar][Foo]
▾ <Component> [Foo]
▾ <Component>
<div>
`);
act(() => (store.componentFilters = [utils.createHOCFilter(true)]));
expect(store).toMatchSnapshot('2: hide all HOCs');
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Component>
<div>
`);
act(() => (store.componentFilters = [utils.createHOCFilter(false)]));
expect(store).toMatchSnapshot('3: disable HOC filter');
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Component> [Bar][Foo]
▾ <Component> [Foo]
▾ <Component>
<div>
`);
});
it('should not send a bridge update if the set of enabled filters has not changed', () => {
@@ -252,12 +338,117 @@ describe('Store component filters', () => {
const container = document.createElement('div');
act(() => ReactDOM.render(<Wrapper shouldSuspend={true} />, container));
expect(store).toMatchSnapshot('1: suspended');
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Wrapper>
▾ <Loading>
<div>
`);
act(() => ReactDOM.render(<Wrapper shouldSuspend={false} />, container));
expect(store).toMatchSnapshot('2: resolved');
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Wrapper>
<Component>
`);
act(() => ReactDOM.render(<Wrapper shouldSuspend={true} />, container));
expect(store).toMatchSnapshot('3: suspended');
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Wrapper>
▾ <Loading>
<div>
`);
});
describe('inline errors and warnings', () => {
it('only counts for unfiltered components', () => {
function ComponentWithWarning() {
console.warn('test-only: render warning');
return null;
}
function ComponentWithError() {
console.error('test-only: render error');
return null;
}
function ComponentWithWarningAndError() {
console.error('test-only: render error');
console.warn('test-only: render warning');
return null;
}
const container = document.createElement('div');
utils.withErrorsOrWarningsIgnored(['test-only:'], () => {
act(
() =>
(store.componentFilters = [
utils.createDisplayNameFilter('Warning'),
utils.createDisplayNameFilter('Error'),
]),
);
act(() =>
ReactDOM.render(
<React.Fragment>
<ComponentWithError />
<ComponentWithWarning />
<ComponentWithWarningAndError />
</React.Fragment>,
container,
),
);
});
expect(store).toMatchInlineSnapshot(`[root]`);
expect(store.errorCount).toBe(0);
expect(store.warningCount).toBe(0);
act(() => (store.componentFilters = []));
expect(store).toMatchInlineSnapshot(`
✕ 2, ⚠ 2
[root]
<ComponentWithError> ✕
<ComponentWithWarning> ⚠
<ComponentWithWarningAndError> ✕⚠
`);
act(
() =>
(store.componentFilters = [utils.createDisplayNameFilter('Warning')]),
);
expect(store).toMatchInlineSnapshot(`
✕ 1, ⚠ 0
[root]
<ComponentWithError> ✕
`);
act(
() =>
(store.componentFilters = [utils.createDisplayNameFilter('Error')]),
);
expect(store).toMatchInlineSnapshot(`
✕ 0, ⚠ 1
[root]
<ComponentWithWarning> ⚠
`);
act(
() =>
(store.componentFilters = [
utils.createDisplayNameFilter('Warning'),
utils.createDisplayNameFilter('Error'),
]),
);
expect(store).toMatchInlineSnapshot(`[root]`);
expect(store.errorCount).toBe(0);
expect(store.warningCount).toBe(0);
act(() => (store.componentFilters = []));
expect(store).toMatchInlineSnapshot(`
✕ 2, ⚠ 2
[root]
<ComponentWithError> ✕
<ComponentWithWarning> ⚠
<ComponentWithWarningAndError> ✕⚠
`);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
import {printStore} from 'react-devtools-shared/src/devtools/utils';
// test() is part of Jest's serializer API
export function test(maybeState) {
if (maybeState === null || typeof maybeState !== 'object') {
return false;
}
// Duck typing at its finest.
return (
maybeState.hasOwnProperty('inspectedElementID') &&
maybeState.hasOwnProperty('ownerFlatTree') &&
maybeState.hasOwnProperty('ownerSubtreeLeafElementID')
);
}
// print() is part of Jest's serializer API
export function print(state, serialize, indent) {
// This is a big of a hack but it works around having to pass in a meta object e.g. {store, state}.
// DevTools tests depend on a global Store object anyway (initialized via setupTest).
const store = global.store;
return printStore(store, false, state);
}
+34
View File
@@ -213,3 +213,37 @@ export function exportImportHelper(bridge: FrontendBridge, store: Store): void {
profilerStore.profilingData = profilingDataFrontend;
});
}
/**
* Runs `fn` while preventing console error and warnings that partially match any given `errorOrWarningMessages` from appearing in the console.
* @param errorOrWarningMessages Messages are matched partially (i.e. indexOf), pre-formatting.
* @param fn
*/
export function withErrorsOrWarningsIgnored<T: void | Promise<void>>(
errorOrWarningMessages: string[],
fn: () => T,
): T {
let resetIgnoredErrorOrWarningMessages = true;
try {
global._ignoredErrorOrWarningMessages = errorOrWarningMessages;
const maybeThenable = fn();
if (
maybeThenable !== undefined &&
typeof maybeThenable.then === 'function'
) {
resetIgnoredErrorOrWarningMessages = false;
return maybeThenable.then(
() => {
global._ignoredErrorOrWarningMessages = [];
},
() => {
global._ignoredErrorOrWarningMessages = [];
},
);
}
} finally {
if (resetIgnoredErrorOrWarningMessages) {
global._ignoredErrorOrWarningMessages = [];
}
}
}
+37 -1
View File
@@ -169,6 +169,9 @@ export default class Agent extends EventEmitter<{|
this._bridge = bridge;
bridge.addListener('clearErrorsAndWarnings', this.clearErrorsAndWarnings);
bridge.addListener('clearErrorsForFiberID', this.clearErrorsForFiberID);
bridge.addListener('clearWarningsForFiberID', this.clearWarningsForFiberID);
bridge.addListener('copyElementPath', this.copyElementPath);
bridge.addListener('deletePath', this.deletePath);
bridge.addListener('getProfilingData', this.getProfilingData);
@@ -226,6 +229,33 @@ export default class Agent extends EventEmitter<{|
return this._rendererInterfaces;
}
clearErrorsAndWarnings = ({rendererID}: {|rendererID: RendererID|}) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
console.warn(`Invalid renderer id "${rendererID}"`);
} else {
renderer.clearErrorsAndWarnings();
}
};
clearErrorsForFiberID = ({id, rendererID}: ElementAndRendererID) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
console.warn(`Invalid renderer id "${rendererID}"`);
} else {
renderer.clearErrorsForFiberID(id);
}
};
clearWarningsForFiberID = ({id, rendererID}: ElementAndRendererID) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
console.warn(`Invalid renderer id "${rendererID}"`);
} else {
renderer.clearWarningsForFiberID(id);
}
};
copyElementPath = ({id, path, rendererID}: CopyElementParams) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
@@ -571,16 +601,22 @@ export default class Agent extends EventEmitter<{|
updateConsolePatchSettings = ({
appendComponentStack,
breakOnConsoleErrors,
showInlineWarningsAndErrors,
}: {|
appendComponentStack: boolean,
breakOnConsoleErrors: boolean,
showInlineWarningsAndErrors: 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 || breakOnConsoleErrors) {
patchConsole({appendComponentStack, breakOnConsoleErrors});
patchConsole({
appendComponentStack,
breakOnConsoleErrors,
showInlineWarningsAndErrors,
});
} else {
unpatchConsole();
}
+69 -29
View File
@@ -22,11 +22,22 @@ const PREFIX_REGEX = /\s{4}(in|at)\s{1}/;
// but we can fallback to looking for location info (e.g. "foo.js:12:345")
const ROW_COLUMN_NUMBER_REGEX = /:\d+:\d+(\n|$)/;
export function isStringComponentStack(text: string): boolean {
return PREFIX_REGEX.test(text) || ROW_COLUMN_NUMBER_REGEX.test(text);
}
type OnErrorOrWarning = (
fiber: Fiber,
type: 'error' | 'warn',
args: Array<any>,
) => void;
const injectedRenderers: Map<
ReactRenderer,
{|
currentDispatcherRef: CurrentDispatcherRef,
getCurrentFiber: () => Fiber | null,
onErrorOrWarning: ?OnErrorOrWarning,
workTagMap: WorkTagMap,
|},
> = new Map();
@@ -54,7 +65,10 @@ export function dangerous_setTargetConsoleForTesting(
// v16 renderers should use this method to inject internals necessary to generate a component stack.
// These internals will be used if the console is patched.
// Injecting them separately allows the console to easily be patched or un-patched later (at runtime).
export function registerRenderer(renderer: ReactRenderer): void {
export function registerRenderer(
renderer: ReactRenderer,
onErrorOrWarning?: OnErrorOrWarning,
): void {
const {
currentDispatcherRef,
getCurrentFiber,
@@ -76,6 +90,7 @@ export function registerRenderer(renderer: ReactRenderer): void {
currentDispatcherRef,
getCurrentFiber,
workTagMap: ReactTypeOfWork,
onErrorOrWarning,
});
}
}
@@ -83,6 +98,7 @@ export function registerRenderer(renderer: ReactRenderer): void {
const consoleSettingsRef = {
appendComponentStack: false,
breakOnConsoleErrors: false,
showInlineWarningsAndErrors: false,
};
// Patches console methods to append component stack for the current fiber.
@@ -90,14 +106,17 @@ const consoleSettingsRef = {
export function patch({
appendComponentStack,
breakOnConsoleErrors,
showInlineWarningsAndErrors,
}: {
appendComponentStack: boolean,
breakOnConsoleErrors: boolean,
showInlineWarningsAndErrors: boolean,
}): void {
// Settings may change after we've patched the console.
// Using a shared ref allows the patch function to read the latest values.
consoleSettingsRef.appendComponentStack = appendComponentStack;
consoleSettingsRef.breakOnConsoleErrors = breakOnConsoleErrors;
consoleSettingsRef.showInlineWarningsAndErrors = showInlineWarningsAndErrors;
if (unpatchFn !== null) {
// Don't patch twice.
@@ -121,32 +140,51 @@ export function patch({
targetConsole[method]);
const overrideMethod = (...args) => {
const latestAppendComponentStack =
consoleSettingsRef.appendComponentStack;
const latestBreakOnConsoleErrors =
consoleSettingsRef.breakOnConsoleErrors;
const lastArg = args.length > 0 ? args[args.length - 1] : null;
const alreadyHasComponentStack =
lastArg !== null && isStringComponentStack(lastArg);
if (latestAppendComponentStack) {
try {
// 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 lastArg = args.length > 0 ? args[args.length - 1] : null;
const alreadyHasComponentStack =
lastArg !== null &&
(PREFIX_REGEX.test(lastArg) ||
ROW_COLUMN_NUMBER_REGEX.test(lastArg));
let shouldAppendWarningStack = false;
if (consoleSettingsRef.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.
shouldAppendWarningStack = !alreadyHasComponentStack;
}
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?)
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
for (const {
currentDispatcherRef,
getCurrentFiber,
workTagMap,
} of injectedRenderers.values()) {
const current: ?Fiber = getCurrentFiber();
if (current != null) {
const shouldShowInlineWarningsAndErrors =
consoleSettingsRef.showInlineWarningsAndErrors &&
(method === 'error' || method === 'warn');
if (shouldAppendWarningStack || shouldShowInlineWarningsAndErrors) {
// Search for the first renderer that has a current Fiber.
// We don't handle the edge case of stacks for more than one (e.g. interleaved renderers?)
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
for (const {
currentDispatcherRef,
getCurrentFiber,
onErrorOrWarning,
workTagMap,
} of injectedRenderers.values()) {
const current: ?Fiber = getCurrentFiber();
if (current != null) {
try {
if (shouldShowInlineWarningsAndErrors) {
// patch() is called by two places: (1) the hook and (2) the renderer backend.
// The backend is what impliments a message queue, so it's the only one that injects onErrorOrWarning.
if (typeof onErrorOrWarning === 'function') {
onErrorOrWarning(
current,
((method: any): 'error' | 'warn'),
// Copy args before we mutate them (e.g. adding the component stack)
alreadyHasComponentStack
? // Replace component stack with an empty string in case there's a string placeholder for it.
[...args.slice(0, -1), '']
: args.slice(),
);
}
}
if (shouldAppendWarningStack) {
const componentStack = getStackByFiberInDevAndProd(
workTagMap,
current,
@@ -155,16 +193,17 @@ export function patch({
if (componentStack !== '') {
args.push(componentStack);
}
break;
}
} catch (error) {
// Don't let a DevTools or React internal error interfere with logging.
} finally {
break;
}
}
} catch (error) {
// Don't let a DevTools or React internal error interfere with logging.
}
}
if (latestBreakOnConsoleErrors) {
if (consoleSettingsRef.breakOnConsoleErrors) {
// --- Welcome to debugging with React DevTools ---
// This debugger statement means that you've enabled the "break on warnings" feature.
// Use the browser's Call Stack panel to step out of this override function-
@@ -177,6 +216,7 @@ export function patch({
};
overrideMethod.__REACT_DEVTOOLS_ORIGINAL_METHOD__ = originalMethod;
originalMethod.__REACT_DEVTOOLS_OVERRIDE_METHOD__ = overrideMethod;
// $FlowFixMe property error|warn is not writable.
targetConsole[method] = overrideMethod;
@@ -779,6 +779,10 @@ export function attach(
state = publicInstance.state || null;
}
// Not implemented
const errors = [];
const warnings = [];
return {
id,
@@ -812,6 +816,8 @@ export function attach(
hooks: null,
props,
state,
errors,
warnings,
// List of owners
owners,
@@ -1040,7 +1046,22 @@ export function attach(
return null;
}
function clearErrorsAndWarnings() {
// Not implemented
}
function clearErrorsForFiberID(id: number) {
// Not implemented
}
function clearWarningsForFiberID(id: number) {
// Not implemented
}
return {
clearErrorsAndWarnings,
clearErrorsForFiberID,
clearWarningsForFiberID,
cleanup,
copyElementPath,
deletePath,
+200 -4
View File
@@ -49,7 +49,9 @@ import {
SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY,
TREE_OPERATION_ADD,
TREE_OPERATION_REMOVE,
TREE_OPERATION_REMOVE_ROOT,
TREE_OPERATION_REORDER_CHILDREN,
TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS,
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
} from '../constants';
import {inspectHooksOfFiber} from 'react-debug-tools';
@@ -76,6 +78,7 @@ import {
MEMO_NUMBER,
MEMO_SYMBOL_STRING,
} from './ReactSymbols';
import {format} from './utils';
import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
import type {
@@ -117,6 +120,7 @@ type ReactTypeOfSideEffectType = {|
NoFlags: number,
PerformedWork: number,
Placement: number,
Incomplete: number,
|};
function getFiberFlags(fiber: Fiber): number {
@@ -143,6 +147,7 @@ export function getInternalReactConstants(
NoFlags: 0b00,
PerformedWork: 0b01,
Placement: 0b10,
Incomplete: 0b10000000000000,
};
// **********************************************************
@@ -479,7 +484,7 @@ export function attach(
ReactTypeOfWork,
ReactTypeOfSideEffect,
} = getInternalReactConstants(renderer.version);
const {NoFlags, PerformedWork, Placement} = ReactTypeOfSideEffect;
const {Incomplete, NoFlags, PerformedWork, Placement} = ReactTypeOfSideEffect;
const {
FunctionComponent,
ClassComponent,
@@ -522,13 +527,104 @@ export function attach(
typeof setSuspenseHandler === 'function' &&
typeof scheduleUpdate === 'function';
// Set of Fibers (IDs) with recently changed number of error/warning messages.
const fibersWithChangedErrorOrWarningCounts: Set<number> = new Set();
// Mapping of fiber IDs to error/warning messages and counts.
const fiberToErrorsMap: Map<number, Map<string, number>> = new Map();
const fiberToWarningsMap: Map<number, Map<string, number>> = new Map();
function clearErrorsAndWarnings() {
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
for (const id of fiberToErrorsMap.keys()) {
fibersWithChangedErrorOrWarningCounts.add(id);
updateMostRecentlyInspectedElementIfNecessary(id);
}
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
for (const id of fiberToWarningsMap.keys()) {
fibersWithChangedErrorOrWarningCounts.add(id);
updateMostRecentlyInspectedElementIfNecessary(id);
}
fiberToErrorsMap.clear();
fiberToWarningsMap.clear();
flushPendingEvents();
}
function clearErrorsForFiberID(id: number) {
if (fiberToErrorsMap.has(id)) {
fiberToErrorsMap.delete(id);
fibersWithChangedErrorOrWarningCounts.add(id);
flushPendingEvents();
}
updateMostRecentlyInspectedElementIfNecessary(id);
}
function clearWarningsForFiberID(id: number) {
if (fiberToWarningsMap.has(id)) {
fiberToWarningsMap.delete(id);
fibersWithChangedErrorOrWarningCounts.add(id);
flushPendingEvents();
}
updateMostRecentlyInspectedElementIfNecessary(id);
}
function updateMostRecentlyInspectedElementIfNecessary(
fiberID: number,
): void {
if (
mostRecentlyInspectedElement !== null &&
mostRecentlyInspectedElement.id === fiberID
) {
hasElementUpdatedSinceLastInspected = true;
}
}
// Called when an error or warning is logged during render, commit, or passive (including unmount functions).
function onErrorOrWarning(
fiber: Fiber,
type: 'error' | 'warn',
args: $ReadOnlyArray<any>,
): void {
const message = format(...args);
// Note that by calling these functions we may be creating the ID for the first time.
// If the Fiber is then never mounted, we are responsible for cleaning up after ourselves.
// This is important because getPrimaryFiber() stores a Fiber in the primaryFibers Set.
// If a Fiber never mounts, and we don't clean up after this code, we could leak.
// Fortunately we would only leak Fibers that have errors/warnings associated with them,
// which is hopefully only a small set and only in DEV mode but this is still not great.
// We should clean up Fibers like this when flushing; see recordPendingErrorsAndWarnings().
const fiberID = getFiberID(getPrimaryFiber(fiber));
// Mark this Fiber as needed its warning/error count updated during the next flush.
fibersWithChangedErrorOrWarningCounts.add(fiberID);
// Update the error/warning messages and counts for the Fiber.
const fiberMap = type === 'error' ? fiberToErrorsMap : fiberToWarningsMap;
const messageMap = fiberMap.get(fiberID);
if (messageMap != null) {
const count = messageMap.get(message) || 0;
messageMap.set(message, count + 1);
} else {
fiberMap.set(fiberID, new Map([[message, 1]]));
}
// If this Fiber is currently being inspected, mark it as needing an udpate as well.
updateMostRecentlyInspectedElementIfNecessary(fiberID);
}
// 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);
registerRendererWithConsole(renderer, onErrorOrWarning);
// The renderer interface can't read these preferences directly,
// because it is stored in localStorage within the context of the extension.
@@ -537,10 +633,13 @@ export function attach(
window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ !== false;
const breakOnConsoleErrors =
window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ === true;
const showInlineWarningsAndErrors =
window.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ === true;
if (appendComponentStack || breakOnConsoleErrors) {
patchConsole({
appendComponentStack,
breakOnConsoleErrors,
showInlineWarningsAndErrors,
});
}
}
@@ -652,8 +751,11 @@ export function attach(
// Recursively unmount all roots.
hook.getFiberRoots(rendererID).forEach(root => {
currentRootID = getFiberID(getPrimaryFiber(root.current));
unmountFiberChildrenRecursively(root.current);
recordUnmount(root.current, false);
// The TREE_OPERATION_REMOVE_ROOT operation serves two purposes:
// 1. It avoids sending unnecessary bridge traffic to clear a root.
// 2. It preserves Fiber IDs when remounting (below) which in turn ID to error/warning mapping.
pushOperation(TREE_OPERATION_REMOVE_ROOT);
flushPendingEvents(root);
currentRootID = -1;
});
@@ -670,6 +772,10 @@ export function attach(
flushPendingEvents(root);
currentRootID = -1;
});
// Also re-evaluate all error and warning counts given the new filters.
reevaluateErrorsAndWarnings();
flushPendingEvents();
}
// NOTICE Keep in sync with get*ForFiber methods
@@ -1055,7 +1161,64 @@ export function attach(
pendingOperations.push(op);
}
function reevaluateErrorsAndWarnings() {
fibersWithChangedErrorOrWarningCounts.clear();
fiberToErrorsMap.forEach((countMap, fiberID) => {
fibersWithChangedErrorOrWarningCounts.add(fiberID);
});
fiberToWarningsMap.forEach((countMap, fiberID) => {
fibersWithChangedErrorOrWarningCounts.add(fiberID);
});
recordPendingErrorsAndWarnings();
}
function recordPendingErrorsAndWarnings() {
fibersWithChangedErrorOrWarningCounts.forEach(fiberID => {
const fiber = idToFiberMap.get(fiberID);
if (fiber != null) {
// Don't send updates for Fibers that didn't mount due to e.g. Suspense or an error boundary.
// We may also need to clean up after ourselves to avoid leaks.
// See inline comments in onErrorOrWarning() for more info.
if (isFiberMountedImpl(fiber) !== MOUNTED) {
fiberToIDMap.delete(fiber);
idToFiberMap.delete(fiberID);
primaryFibers.delete(fiber);
return;
}
let errorCount = 0;
let warningCount = 0;
if (!shouldFilterFiber(fiber)) {
const errorCountsMap = fiberToErrorsMap.get(fiberID);
const warningCountsMap = fiberToWarningsMap.get(fiberID);
if (errorCountsMap != null) {
errorCountsMap.forEach(count => {
errorCount += count;
});
}
if (warningCountsMap != null) {
warningCountsMap.forEach(count => {
warningCount += count;
});
}
}
pushOperation(TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS);
pushOperation(fiberID);
pushOperation(errorCount);
pushOperation(warningCount);
}
});
fibersWithChangedErrorOrWarningCounts.clear();
}
function flushPendingEvents(root: Object): void {
// Add any pending errors and warnings to the operations array.
// We do this just before flushing, so we can ignore errors for no-longer-mounted Fibers.
recordPendingErrorsAndWarnings();
if (
pendingOperations.length === 0 &&
pendingRealUnmountedIDs.length === 0 &&
@@ -2028,17 +2191,42 @@ export function attach(
// https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberTreeReflection.js
function isFiberMountedImpl(fiber: Fiber): number {
let node = fiber;
let prevNode = null;
if (!fiber.alternate) {
// If there is no alternate, this might be a new tree that isn't inserted
// yet. If it is, then it will have a pending insertion effect on it.
if ((getFiberFlags(node) & Placement) !== NoFlags) {
return MOUNTING;
}
// This indicates an error during render.
if ((getFiberFlags(node) & Incomplete) !== NoFlags) {
return UNMOUNTED;
}
while (node.return) {
prevNode = node;
node = node.return;
if ((getFiberFlags(node) & Placement) !== NoFlags) {
return MOUNTING;
}
// This indicates an error during render.
if ((getFiberFlags(node) & Incomplete) !== NoFlags) {
return UNMOUNTED;
}
// If this node is inside of a timed out suspense subtree, we should also ignore errors/warnings.
const isTimedOutSuspense =
node.tag === SuspenseComponent && node.memoizedState !== null;
if (isTimedOutSuspense) {
// Note that this does not include errors/warnings in the Fallback tree though!
const primaryChildFragment = node.child;
const fallbackChildFragment = primaryChildFragment
? primaryChildFragment.sibling
: null;
if (prevNode !== fallbackChildFragment) {
return UNMOUNTED;
}
}
}
} else {
while (node.return) {
@@ -2455,6 +2643,9 @@ export function attach(
rootType = fiberRoot._debugRootType;
}
const errors = fiberToErrorsMap.get(id) || new Map();
const warnings = fiberToWarningsMap.get(id) || new Map();
return {
id,
@@ -2497,6 +2688,8 @@ export function attach(
hooks,
props: memoizedProps,
state: usesHooks ? null : memoizedState,
errors: Array.from(errors.entries()),
warnings: Array.from(warnings.entries()),
// List of owners
owners,
@@ -3425,6 +3618,9 @@ export function attach(
return {
cleanup,
clearErrorsAndWarnings,
clearErrorsForFiberID,
clearWarningsForFiberID,
copyElementPath,
deletePath,
findNativeNodesForFiberID,
+5
View File
@@ -234,6 +234,8 @@ export type InspectedElement = {|
props: Object | null,
state: Object | null,
key: number | string | null,
errors: Array<[string, number]>,
warnings: Array<[string, number]>,
// List of owners
owners: Array<Owner> | null,
@@ -294,6 +296,9 @@ type Type = 'props' | 'hooks' | 'state' | 'context';
export type RendererInterface = {
cleanup: () => void,
clearErrorsAndWarnings: () => void,
clearErrorsForFiberID: (id: number) => void,
clearWarningsForFiberID: (id: number) => void,
copyElementPath: (id: number, path: Array<string | number>) => void,
deletePath: (
type: Type,
+49
View File
@@ -134,3 +134,52 @@ export function serializeToString(data: any): string {
return value;
});
}
// based on https://github.com/tmpfs/format-util/blob/0e62d430efb0a1c51448709abd3e2406c14d8401/format.js#L1
// based on https://developer.mozilla.org/en-US/docs/Web/API/console#Using_string_substitutions
// Implements s, d, i and f placeholders
export function format(
maybeMessage: any,
...inputArgs: $ReadOnlyArray<any>
): string {
if (typeof maybeMessage !== 'string') {
return [maybeMessage, ...inputArgs].join(' ');
}
const re = /(%?)(%([jds]))/g;
const args = inputArgs.slice();
let formatted: string = maybeMessage;
if (args.length) {
formatted = formatted.replace(re, (match, escaped, ptn, flag) => {
let arg = args.shift();
switch (flag) {
case 's':
arg += '';
break;
case 'd':
case 'i':
arg = parseInt(arg, 10).toString();
break;
case 'f':
arg = parseFloat(arg).toString();
break;
}
if (!escaped) {
return arg;
}
args.unshift(arg);
return match;
});
}
// arguments remain after formatting
if (args.length) {
formatted += ' ' + args.join(' ');
}
// update escaped %% values
formatted = formatted.replace(/%{2,2}/g, '%');
return '' + formatted;
}
+4
View File
@@ -114,6 +114,7 @@ type NativeStyleEditor_SetValueParams = {|
type UpdateConsolePatchSettingsParams = {|
appendComponentStack: boolean,
breakOnConsoleErrors: boolean,
showInlineWarningsAndErrors: boolean,
|};
type BackendEvents = {|
@@ -141,7 +142,10 @@ type BackendEvents = {|
|};
type FrontendEvents = {|
clearErrorsAndWarnings: [{|rendererID: RendererID|}],
clearErrorsForFiberID: [ElementAndRendererID],
clearNativeElementHighlight: [],
clearWarningsForFiberID: [ElementAndRendererID],
copyElementPath: [CopyElementPathParams],
deletePath: [DeletePath],
getOwnersList: [ElementAndRendererID],
+5
View File
@@ -14,6 +14,8 @@ export const TREE_OPERATION_ADD = 1;
export const TREE_OPERATION_REMOVE = 2;
export const TREE_OPERATION_REORDER_CHILDREN = 3;
export const TREE_OPERATION_UPDATE_TREE_BASE_DURATION = 4;
export const TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS = 5;
export const TREE_OPERATION_REMOVE_ROOT = 6;
export const LOCAL_STORAGE_FILTER_PREFERENCES_KEY =
'React::DevTools::componentFilters';
@@ -33,6 +35,9 @@ export const LOCAL_STORAGE_SHOULD_BREAK_ON_CONSOLE_ERRORS =
export const LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY =
'React::DevTools::appendComponentStack';
export const LOCAL_STORAGE_SHOW_INLINE_WARNINGS_AND_ERRORS_KEY =
'React::DevTools::showInlineWarningsAndErrors';
export const LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY =
'React::DevTools::traceUpdatesEnabled';
+152
View File
@@ -12,7 +12,9 @@ import {inspect} from 'util';
import {
TREE_OPERATION_ADD,
TREE_OPERATION_REMOVE,
TREE_OPERATION_REMOVE_ROOT,
TREE_OPERATION_REORDER_CHILDREN,
TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS,
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
} from '../constants';
import {ElementTypeRoot} from '../types';
@@ -78,11 +80,22 @@ export default class Store extends EventEmitter<{|
|}> {
_bridge: FrontendBridge;
// Computed whenever _errorsAndWarnings Map changes.
_cachedErrorCount: number = 0;
_cachedWarningCount: number = 0;
_cachedErrorAndWarningTuples: Array<{|id: number, index: number|}> = [];
// Should new nodes be collapsed by default when added to the tree?
_collapseNodesByDefault: boolean = true;
_componentFilters: Array<ComponentFilter>;
// Map of ID to number of recorded error and warning message IDs.
_errorsAndWarnings: Map<
number,
{|errorCount: number, warningCount: number|},
> = new Map();
// At least one of the injected renderers contains (DEV only) owner metadata.
_hasOwnerMetadata: boolean = false;
@@ -289,6 +302,10 @@ export default class Store extends EventEmitter<{|
this.emit('componentFilters');
}
get errorCount(): number {
return this._cachedErrorCount;
}
get hasOwnerMetadata(): boolean {
return this._hasOwnerMetadata;
}
@@ -357,6 +374,46 @@ export default class Store extends EventEmitter<{|
return this._unsupportedRendererVersionDetected;
}
get warningCount(): number {
return this._cachedWarningCount;
}
clearErrorsAndWarnings(): void {
this._rootIDToRendererID.forEach(rendererID => {
this._bridge.send('clearErrorsAndWarnings', {
rendererID,
});
});
}
clearErrorsForElement(id: number): void {
const rendererID = this.getRendererIDForElement(id);
if (rendererID === null) {
console.warn(
`Unable to find rendererID for element ${id} when clearing errors.`,
);
} else {
this._bridge.send('clearErrorsForFiberID', {
rendererID,
id,
});
}
}
clearWarningsForElement(id: number): void {
const rendererID = this.getRendererIDForElement(id);
if (rendererID === null) {
console.warn(
`Unable to find rendererID for element ${id} when clearing warnings.`,
);
} else {
this._bridge.send('clearWarningsForFiberID', {
rendererID,
id,
});
}
}
containsElement(id: number): boolean {
return this._idToElement.get(id) != null;
}
@@ -425,6 +482,17 @@ export default class Store extends EventEmitter<{|
return element;
}
// Returns a tuple of [id, index]
getElementsWithErrorsAndWarnings(): Array<{|id: number, index: number|}> {
return this._cachedErrorAndWarningTuples;
}
getErrorAndWarningCountForElementID(
id: number,
): {|errorCount: number, warningCount: number|} {
return this._errorsAndWarnings.get(id) || {errorCount: 0, warningCount: 0};
}
getIndexOfElementID(id: number): number | null {
const element = this.getElementByID(id);
@@ -709,6 +777,7 @@ export default class Store extends EventEmitter<{|
}
let haveRootsChanged = false;
let haveErrorsOrWarningsChanged = false;
// The first two values are always rendererID and rootID
const rendererID = operations[0];
@@ -910,9 +979,43 @@ export default class Store extends EventEmitter<{|
set.delete(id);
}
}
if (this._errorsAndWarnings.has(id)) {
this._errorsAndWarnings.delete(id);
haveErrorsOrWarningsChanged = true;
}
}
break;
}
case TREE_OPERATION_REMOVE_ROOT: {
i += 1;
const id = operations[1];
if (__DEBUG__) {
debug(`Remove root ${id}`);
}
const recursivelyDeleteElements = elementID => {
const element = this._idToElement.get(elementID);
this._idToElement.delete(elementID);
if (element) {
// Mostly for Flow's sake
for (let index = 0; index < element.children.length; index++) {
recursivelyDeleteElements(element.children[index]);
}
}
};
const root = ((this._idToElement.get(id): any): Element);
recursivelyDeleteElements(id);
this._rootIDToCapabilities.delete(id);
this._rootIDToRendererID.delete(id);
this._roots = this._roots.filter(rootID => rootID !== id);
this._weightAcrossRoots -= root.weight;
break;
}
case TREE_OPERATION_REORDER_CHILDREN: {
const id = ((operations[i + 1]: any): number);
const numChildren = ((operations[i + 2]: any): number);
@@ -958,6 +1061,20 @@ export default class Store extends EventEmitter<{|
// The profiler UI uses them lazily in order to generate the tree.
i += 3;
break;
case TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS:
const id = operations[i + 1];
const errorCount = operations[i + 2];
const warningCount = operations[i + 3];
i += 4;
if (errorCount > 0 || warningCount > 0) {
this._errorsAndWarnings.set(id, {errorCount, warningCount});
} else if (this._errorsAndWarnings.has(id)) {
this._errorsAndWarnings.delete(id);
}
haveErrorsOrWarningsChanged = true;
break;
default:
throw Error(`Unsupported Bridge operation ${operation}`);
}
@@ -965,6 +1082,41 @@ export default class Store extends EventEmitter<{|
this._revision++;
if (haveErrorsOrWarningsChanged) {
let errorCount = 0;
let warningCount = 0;
this._errorsAndWarnings.forEach(entry => {
errorCount += entry.errorCount;
warningCount += entry.warningCount;
});
this._cachedErrorCount = errorCount;
this._cachedWarningCount = warningCount;
const errorAndWarningTuples: Array<{|id: number, index: number|}> = [];
this._errorsAndWarnings.forEach((_, id) => {
const index = this.getIndexOfElementID(id);
if (index !== null) {
let low = 0;
let high = errorAndWarningTuples.length;
while (low < high) {
const mid = (low + high) >> 1;
if (errorAndWarningTuples[mid].index > index) {
high = mid;
} else {
low = mid + 1;
}
}
errorAndWarningTuples.splice(low, 0, {id, index});
}
});
this._cachedErrorAndWarningTuples = errorAndWarningTuples;
}
if (haveRootsChanged) {
const prevSupportsProfiling = this._supportsProfiling;
+83 -26
View File
@@ -10,6 +10,7 @@
import JSON5 from 'json5';
import type {Element} from './views/Components/types';
import type {StateContext} from './views/Components/TreeContext';
import type Store from './store';
export function printElement(element: Element, includeWeight: boolean = false) {
@@ -49,39 +50,95 @@ export function printOwnersList(
.join('\n');
}
export function printStore(store: Store, includeWeight: boolean = false) {
export function printStore(
store: Store,
includeWeight: boolean = false,
state: StateContext | null = null,
) {
const snapshotLines = [];
let rootWeight = 0;
store.roots.forEach(rootID => {
const {weight} = ((store.getElementByID(rootID): any): Element);
snapshotLines.push('[root]' + (includeWeight ? ` (${weight})` : ''));
for (let i = rootWeight; i < rootWeight + weight; i++) {
const element = store.getElementAtIndex(i);
if (element == null) {
throw Error(`Could not find element at index ${i}`);
}
snapshotLines.push(printElement(element, includeWeight));
function printSelectedMarker(index: number): string {
if (state === null) {
return '';
}
rootWeight += weight;
});
// Make sure the pretty-printed test align with the Store's reported number of total rows.
if (rootWeight !== store.numElements) {
throw Error(
`Inconsistent Store state. Individual root weights (${rootWeight}) do not match total weight (${store.numElements})`,
);
return state.selectedElementIndex === index ? `` : ' ';
}
// If roots have been unmounted, verify that they've been removed from maps.
// This helps ensure the Store doesn't leak memory.
store.assertExpectedRootMapSizes();
function printErrorsAndWarnings(element: Element): string {
const {
errorCount,
warningCount,
} = store.getErrorAndWarningCountForElementID(element.id);
if (errorCount === 0 && warningCount === 0) {
return '';
}
return ` ${errorCount > 0 ? '✕' : ''}${warningCount > 0 ? '⚠' : ''}`;
}
const ownerFlatTree = state !== null ? state.ownerFlatTree : null;
if (ownerFlatTree !== null) {
snapshotLines.push(
'[owners]' + (includeWeight ? ` (${ownerFlatTree.length})` : ''),
);
ownerFlatTree.forEach((element, index) => {
const printedSelectedMarker = printSelectedMarker(index);
const printedElement = printElement(element, false);
const printedErrorsAndWarnings = printErrorsAndWarnings(element);
snapshotLines.push(
`${printedSelectedMarker}${printedElement}${printedErrorsAndWarnings}`,
);
});
} else {
const errorsAndWarnings = store._errorsAndWarnings;
if (errorsAndWarnings.size > 0) {
let errorCount = 0;
let warningCount = 0;
errorsAndWarnings.forEach(entry => {
errorCount += entry.errorCount;
warningCount += entry.warningCount;
});
snapshotLines.push(`${errorCount}, ⚠ ${warningCount}`);
}
store.roots.forEach(rootID => {
const {weight} = ((store.getElementByID(rootID): any): Element);
const maybeWeightLabel = includeWeight ? ` (${weight})` : '';
// Store does not (yet) expose a way to get errors/warnings per root.
snapshotLines.push(`[root]${maybeWeightLabel}`);
for (let i = rootWeight; i < rootWeight + weight; i++) {
const element = store.getElementAtIndex(i);
if (element == null) {
throw Error(`Could not find element at index ${i}`);
}
const printedSelectedMarker = printSelectedMarker(i);
const printedElement = printElement(element, includeWeight);
const printedErrorsAndWarnings = printErrorsAndWarnings(element);
snapshotLines.push(
`${printedSelectedMarker}${printedElement}${printedErrorsAndWarnings}`,
);
}
rootWeight += weight;
});
// Make sure the pretty-printed test align with the Store's reported number of total rows.
if (rootWeight !== store.numElements) {
throw Error(
`Inconsistent Store state. Individual root weights (${rootWeight}) do not match total weight (${store.numElements})`,
);
}
// If roots have been unmounted, verify that they've been removed from maps.
// This helps ensure the Store doesn't leak memory.
store.assertExpectedRootMapSizes();
}
return snapshotLines.join('\n');
}
@@ -73,3 +73,21 @@
.Badge {
margin-left: 0.25rem;
}
.ErrorIcon,
.ErrorIconContrast,
.WarningIcon,
.WarningIconContrast {
height: 0.75rem !important;
width: 0.75rem !important;
margin-left: 0.25rem;
}
.ErrorIcon {
color: var(--color-console-error-icon);
}
.WarningIcon {
color: var(--color-console-warning-icon);
}
.ErrorIconContrast, .WarningIconContrast {
color: var(--color-component-name);
}
@@ -14,12 +14,15 @@ import Badge from './Badge';
import ButtonIcon from '../ButtonIcon';
import {createRegExp} from '../utils';
import {TreeDispatcherContext, TreeStateContext} from './TreeContext';
import {SettingsContext} from '../Settings/SettingsContext';
import {StoreContext} from '../context';
import {useSubscription} from '../hooks';
import type {ItemData} from './Tree';
import type {Element} from './types';
import type {Element as ElementType} from './types';
import styles from './Element.css';
import Icon from '../Icon';
type Props = {
data: ItemData,
@@ -28,12 +31,13 @@ type Props = {
...
};
export default function ElementView({data, index, style}: Props) {
export default function Element({data, index, style}: Props) {
const store = useContext(StoreContext);
const {ownerFlatTree, ownerID, selectedElementID} = useContext(
TreeStateContext,
);
const dispatch = useContext(TreeDispatcherContext);
const {showInlineWarningsAndErrors} = React.useContext(SettingsContext);
const element =
ownerFlatTree !== null
@@ -46,6 +50,24 @@ export default function ElementView({data, index, style}: Props) {
const id = element === null ? null : element.id;
const isSelected = selectedElementID === id;
const errorsAndWarningsSubscription = useMemo(
() => ({
getCurrentValue: () =>
element === null
? {errorCount: 0, warningCount: 0}
: store.getErrorAndWarningCountForElementID(element.id),
subscribe: (callback: Function) => {
store.addListener('mutated', callback);
return () => store.removeListener('mutated', callback);
},
}),
[store, element],
);
const {errorCount, warningCount} = useSubscription<{|
errorCount: number,
warningCount: number,
|}>(errorsAndWarningsSubscription);
const handleDoubleClick = () => {
if (id !== null) {
dispatch({type: 'SELECT_OWNER', payload: id});
@@ -81,7 +103,7 @@ export default function ElementView({data, index, style}: Props) {
// Handle elements that are removed from the tree while an async render is in progress.
if (element == null) {
console.warn(`<ElementView> Could not find element at index ${index}`);
console.warn(`<Element> Could not find element at index ${index}`);
// This return needs to happen after hooks, since hooks can't be conditional.
return null;
@@ -93,7 +115,7 @@ export default function ElementView({data, index, style}: Props) {
hocDisplayNames,
key,
type,
} = ((element: any): Element);
} = ((element: any): ElementType);
let className = styles.Element;
if (isSelected) {
@@ -148,6 +170,26 @@ export default function ElementView({data, index, style}: Props) {
/>
</Badge>
) : null}
{showInlineWarningsAndErrors && errorCount > 0 && (
<Icon
type="error"
className={
isSelected && treeFocused
? styles.ErrorIconContrast
: styles.ErrorIcon
}
/>
)}
{showInlineWarningsAndErrors && warningCount > 0 && (
<Icon
type="warning"
className={
isSelected && treeFocused
? styles.WarningIconContrast
: styles.WarningIcon
}
/>
)}
</div>
</div>
);
@@ -160,7 +202,7 @@ const swallowDoubleClick = event => {
};
type ExpandCollapseToggleProps = {|
element: Element,
element: ElementType,
store: Store,
|};
@@ -51,10 +51,13 @@ export type GetInspectedElement = (
id: number,
) => InspectedElementFrontend | null;
type RefreshInspectedElement = () => void;
export type InspectedElementContextType = {|
copyInspectedElementPath: CopyInspectedElementPath,
getInspectedElementPath: GetInspectedElementPath,
getInspectedElement: GetInspectedElement,
refreshInspectedElement: RefreshInspectedElement,
storeAsGlobal: StoreAsGlobal,
|};
@@ -159,6 +162,15 @@ function InspectedElementContextController({children}: Props) {
// would itself be blocked by the same render that suspends (waiting for the data).
const {selectedElementID} = useContext(TreeStateContext);
const refreshInspectedElement = useCallback<RefreshInspectedElement>(() => {
if (selectedElementID !== null) {
const rendererID = store.getRendererIDForElement(selectedElementID);
if (rendererID !== null) {
bridge.send('inspectElement', {id: selectedElementID, rendererID});
}
}
}, [bridge, selectedElementID]);
const [
currentlyInspectedElement,
setCurrentlyInspectedElement,
@@ -217,6 +229,8 @@ function InspectedElementContextController({children}: Props) {
rootType,
state,
key,
errors,
warnings,
} = ((data.value: any): InspectedElementBackend);
const inspectedElement: InspectedElementFrontend = {
@@ -257,6 +271,8 @@ function InspectedElementContextController({children}: Props) {
hooks: hydrateHelper(hooks),
props: hydrateHelper(props),
state: hydrateHelper(state),
errors,
warnings,
};
element = store.getElementByID(id);
@@ -343,6 +359,7 @@ function InspectedElementContextController({children}: Props) {
copyInspectedElementPath,
getInspectedElement,
getInspectedElementPath,
refreshInspectedElement,
storeAsGlobal,
}),
// InspectedElement is used to invalidate the cache and schedule an update with React.
@@ -351,6 +368,7 @@ function InspectedElementContextController({children}: Props) {
currentlyInspectedElement,
getInspectedElement,
getInspectedElementPath,
refreshInspectedElement,
storeAsGlobal,
],
);
@@ -0,0 +1,60 @@
.ErrorTree, .WarningTree {
padding: 0.25rem 0 0 0;
}
.HeaderRow {
padding: 0 0.25rem;
}
.HeaderRow {
padding: 0 0.25rem;
}
.Error, .Warning {
padding: 0 0.5rem;
display: flex;
align-items: center;
}
.Error {
border-top: 1px solid var(--color-console-error-border);
background-color: var(--color-console-error-background);
color: var(--color-error-text);
padding: 0 0.5rem;
}
.Warning {
border-top: 1px solid var(--color-console-warning-border);
background-color: var(--color-console-warning-background);
color: var(--color-warning-text);
padding: 0 0.5rem;
}
.Message {
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ErrorBadge,
.WarningBadge {
display: inline-block;
width: 0.75rem;
height: 0.75rem;
flex: 0 0 0.75rem;
line-height: 0.75rem;
text-align: center;
border-radius: 0.25rem;
margin-right: 0.25rem;
font-size: var(--font-size-monospace-small);
}
.ErrorBadge {
background-color: var(--color-console-error-icon);
color: var(--color-console-error-badge-text);
}
.WarningBadge {
background-color: var(--color-console-warning-icon);
color: var(--color-console-warning-badge-text);
}
@@ -0,0 +1,157 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import * as React from 'react';
import {useContext} from 'react';
import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
import Store from '../../store';
import sharedStyles from './InspectedElementSharedStyles.css';
import styles from './InspectedElementErrorsAndWarningsTree.css';
import {SettingsContext} from '../Settings/SettingsContext';
import {InspectedElementContext} from './InspectedElementContext';
import type {InspectedElement} from './types';
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
type Props = {|
bridge: FrontendBridge,
inspectedElement: InspectedElement,
store: Store,
|};
export default function InspectedElementErrorsAndWarningsTree({
bridge,
inspectedElement,
store,
}: Props) {
const {refreshInspectedElement} = useContext(InspectedElementContext);
const {showInlineWarningsAndErrors} = useContext(SettingsContext);
if (!showInlineWarningsAndErrors) {
return null;
}
const {errors, warnings} = inspectedElement;
const clearErrors = () => {
const {id} = inspectedElement;
store.clearErrorsForElement(id);
// Immediately poll for updated data.
// This avoids a delay between clicking the clear button and refreshing errors.
// Ideally this would be done with useTranstion but that requires updating to a newer Cache strategy.
refreshInspectedElement();
};
const clearWarnings = () => {
const {id} = inspectedElement;
store.clearWarningsForElement(id);
// Immediately poll for updated data.
// This avoids a delay between clicking the clear button and refreshing warnings.
// Ideally this would be done with useTranstion but that requires updating to a newer Cache strategy.
refreshInspectedElement();
};
return (
<React.Fragment>
{errors.length > 0 && (
<Tree
badgeClassName={styles.ErrorBadge}
bridge={bridge}
className={styles.ErrorTree}
clearMessages={clearErrors}
entries={errors}
label="errors"
messageClassName={styles.Error}
/>
)}
{warnings.length > 0 && (
<Tree
badgeClassName={styles.WarningBadge}
bridge={bridge}
className={styles.WarningTree}
clearMessages={clearWarnings}
entries={warnings}
label="warnings"
messageClassName={styles.Warning}
/>
)}
</React.Fragment>
);
}
type TreeProps = {|
badgeClassName: string,
actions: React$Node,
className: string,
clearMessages: () => {},
entries: Array<[string, number]>,
label: string,
messageClassName: string,
|};
function Tree({
badgeClassName,
actions,
className,
clearMessages,
entries,
label,
messageClassName,
}: TreeProps) {
if (entries.length === 0) {
return null;
}
return (
<div className={`${sharedStyles.InspectedElementTree} ${className}`}>
<div className={`${sharedStyles.HeaderRow} ${styles.HeaderRow}`}>
<div className={sharedStyles.Header}>{label}</div>
<Button
onClick={clearMessages}
title={`Clear all ${label} for this component`}>
<ButtonIcon type="clear" />
</Button>
</div>
{entries.map(([message, count], index) => (
<ErrorOrWarningView
key={`${label}-${index}`}
badgeClassName={badgeClassName}
className={messageClassName}
count={count}
message={message}
/>
))}
</div>
);
}
type ErrorOrWarningViewProps = {|
badgeClassName: string,
className: string,
count: number,
message: string,
|};
function ErrorOrWarningView({
className,
badgeClassName,
count,
message,
}: ErrorOrWarningViewProps) {
return (
<div className={className}>
{count > 1 && <div className={badgeClassName}>{count}</div>}
<div className={styles.Message} title={message}>
{message}
</div>
</div>
);
}
@@ -12,6 +12,8 @@
}
.Header {
display: flex;
align-items: center;
flex: 1 1;
font-family: var(--font-family-sans);
}
@@ -19,6 +19,7 @@ import ButtonIcon from '../ButtonIcon';
import Icon from '../Icon';
import HocBadges from './HocBadges';
import InspectedElementContextTree from './InspectedElementContextTree';
import InspectedElementErrorsAndWarningsTree from './InspectedElementErrorsAndWarningsTree';
import InspectedElementHooksTree from './InspectedElementHooksTree';
import InspectedElementPropsTree from './InspectedElementPropsTree';
import InspectedElementStateTree from './InspectedElementStateTree';
@@ -120,6 +121,13 @@ export default function InspectedElementView({
store={store}
/>
<InspectedElementErrorsAndWarningsTree
bridge={bridge}
getInspectedElementPath={getInspectedElementPath}
inspectedElement={inspectedElement}
store={store}
/>
<NativeStyleEditor />
{showRenderedBy && (
@@ -85,42 +85,44 @@ export default function SearchInput(props: Props) {
value={searchText}
/>
{!!searchText && (
<span className={styles.IndexLabel}>
{Math.min(searchIndex + 1, searchResults.length)} |{' '}
{searchResults.length}
</span>
<React.Fragment>
<span className={styles.IndexLabel}>
{Math.min(searchIndex + 1, searchResults.length)} |{' '}
{searchResults.length}
</span>
<div className={styles.LeftVRule} />
<Button
className={styles.IconButton}
disabled={!searchText}
onClick={() => dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'})}
title={
<React.Fragment>
Scroll to previous search result (<kbd>Shift</kbd> +{' '}
<kbd>Enter</kbd>)
</React.Fragment>
}>
<ButtonIcon type="up" />
</Button>
<Button
className={styles.IconButton}
disabled={!searchText}
onClick={() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'})}
title={
<React.Fragment>
Scroll to next search result (<kbd>Enter</kbd>)
</React.Fragment>
}>
<ButtonIcon type="down" />
</Button>
<Button
className={styles.IconButton}
disabled={!searchText}
onClick={resetSearch}
title="Reset search">
<ButtonIcon type="close" />
</Button>
</React.Fragment>
)}
<div className={styles.LeftVRule} />
<Button
className={styles.IconButton}
disabled={!searchText}
onClick={() => dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'})}
title={
<React.Fragment>
Scroll to previous search result (<kbd>Shift</kbd> +{' '}
<kbd>Enter</kbd>)
</React.Fragment>
}>
<ButtonIcon type="up" />
</Button>
<Button
className={styles.IconButton}
disabled={!searchText}
onClick={() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'})}
title={
<React.Fragment>
Scroll to next search result (<kbd>Enter</kbd>)
</React.Fragment>
}>
<ButtonIcon type="down" />
</Button>
<Button
className={styles.IconButton}
disabled={!searchText}
onClick={resetSearch}
title="Reset search">
<ButtonIcon type="close" />
</Button>
</div>
);
}
@@ -44,6 +44,7 @@
.VRule {
height: 20px;
width: 1px;
flex: 0 0 1px;
margin: 0 0.5rem;
background-color: var(--color-border);
}
@@ -58,3 +59,23 @@
font-size: var(--font-size-sans-large);
color: var(--color-dim);
}
.IconAndCount {
display: flex;
align-items: center;
font-size: var(--font-size-sans-normal);
}
.ErrorIcon, .WarningIcon {
width: 0.75rem;
height: 0.75rem;
margin-left: 0.25rem;
margin-right: 0.25rem;
flex: 0 0 auto;
}
.ErrorIcon {
color: var(--color-console-error-icon);
}
.WarningIcon {
color: var(--color-console-warning-icon);
}
@@ -21,18 +21,21 @@ import {
import AutoSizer from 'react-virtualized-auto-sizer';
import {FixedSizeList} from 'react-window';
import {TreeDispatcherContext, TreeStateContext} from './TreeContext';
import Icon from '../Icon';
import {SettingsContext} from '../Settings/SettingsContext';
import {BridgeContext, StoreContext} from '../context';
import ElementView from './Element';
import Element from './Element';
import InspectHostNodesToggle from './InspectHostNodesToggle';
import OwnersStack from './OwnersStack';
import SearchInput from './SearchInput';
import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle';
import SelectedTreeHighlight from './SelectedTreeHighlight';
import TreeFocusedContext from './TreeFocusedContext';
import {useHighlightNativeElement} from '../hooks';
import {useHighlightNativeElement, useSubscription} from '../hooks';
import styles from './Tree.css';
import ButtonIcon from '../ButtonIcon';
import Button from '../Button';
// Never indent more than this number of pixels (even if we have the room).
const DEFAULT_INDENTATION_SIZE = 12;
@@ -71,7 +74,7 @@ export default function Tree(props: Props) {
const [treeFocused, setTreeFocused] = useState<boolean>(false);
const {lineHeight} = useContext(SettingsContext);
const {lineHeight, showInlineWarningsAndErrors} = useContext(SettingsContext);
// Make sure a newly selected element is visible in the list.
// This is helpful for things like the owners list and search.
@@ -301,6 +304,29 @@ export default function Tree(props: Props) {
[store],
);
const handlePreviousErrorOrWarningClick = React.useCallback(() => {
dispatch({type: 'SELECT_PREVIOUS_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE'});
}, []);
const handleNextErrorOrWarningClick = React.useCallback(() => {
dispatch({type: 'SELECT_NEXT_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE'});
}, []);
const errorsOrWarningsSubscription = useMemo(
() => ({
getCurrentValue: () => ({
errors: store.errorCount,
warnings: store.warningCount,
}),
subscribe: (callback: Function) => {
store.addListener('mutated', callback);
return () => store.removeListener('mutated', callback);
},
}),
[store],
);
const {errors, warnings} = useSubscription(errorsOrWarningsSubscription);
return (
<TreeFocusedContext.Provider value={treeFocused}>
<div className={styles.Tree} ref={treeRef}>
@@ -315,6 +341,40 @@ export default function Tree(props: Props) {
{ownerID !== null ? <OwnersStack /> : <SearchInput />}
</Suspense>
<div className={styles.VRule} />
{showInlineWarningsAndErrors &&
ownerID === null &&
(errors > 0 || warnings > 0) && (
<React.Fragment>
{errors > 0 && (
<div className={styles.IconAndCount}>
<Icon className={styles.ErrorIcon} type="error" />
{errors}
</div>
)}
{warnings > 0 && (
<div className={styles.IconAndCount}>
<Icon className={styles.WarningIcon} type="warning" />
{warnings}
</div>
)}
<Button
onClick={handlePreviousErrorOrWarningClick}
title="Scroll to previous error or warning">
<ButtonIcon type="up" />
</Button>
<Button
onClick={handleNextErrorOrWarningClick}
title="Scroll to next error or warning">
<ButtonIcon type="down" />
</Button>
<Button
onClick={() => store.clearErrorsAndWarnings()}
title="Clear all errors and warnings">
<ButtonIcon type="clear" />
</Button>
<div className={styles.VRule} />
</React.Fragment>
)}
<SettingsModalContextToggle />
</div>
<div
@@ -339,7 +399,7 @@ export default function Tree(props: Props) {
itemSize={lineHeight}
ref={listCallbackRef}
width={width}>
{ElementView}
{Element}
</FixedSizeList>
)}
</AutoSizer>
@@ -93,6 +93,9 @@ type ACTION_SELECT_ELEMENT_BY_ID = {|
type ACTION_SELECT_NEXT_ELEMENT_IN_TREE = {|
type: 'SELECT_NEXT_ELEMENT_IN_TREE',
|};
type ACTION_SELECT_NEXT_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE = {|
type: 'SELECT_NEXT_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE',
|};
type ACTION_SELECT_NEXT_SIBLING_IN_TREE = {|
type: 'SELECT_NEXT_SIBLING_IN_TREE',
|};
@@ -106,6 +109,9 @@ type ACTION_SELECT_PARENT_ELEMENT_IN_TREE = {|
type ACTION_SELECT_PREVIOUS_ELEMENT_IN_TREE = {|
type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE',
|};
type ACTION_SELECT_PREVIOUS_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE = {|
type: 'SELECT_PREVIOUS_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE',
|};
type ACTION_SELECT_PREVIOUS_SIBLING_IN_TREE = {|
type: 'SELECT_PREVIOUS_SIBLING_IN_TREE',
|};
@@ -132,10 +138,12 @@ type Action =
| ACTION_SELECT_ELEMENT_AT_INDEX
| ACTION_SELECT_ELEMENT_BY_ID
| ACTION_SELECT_NEXT_ELEMENT_IN_TREE
| ACTION_SELECT_NEXT_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE
| ACTION_SELECT_NEXT_SIBLING_IN_TREE
| ACTION_SELECT_OWNER
| ACTION_SELECT_PARENT_ELEMENT_IN_TREE
| ACTION_SELECT_PREVIOUS_ELEMENT_IN_TREE
| ACTION_SELECT_PREVIOUS_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE
| ACTION_SELECT_PREVIOUS_SIBLING_IN_TREE
| ACTION_SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE
| ACTION_SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE
@@ -372,6 +380,83 @@ function reduceTreeState(store: Store, state: State, action: Action): State {
}
}
break;
case 'SELECT_PREVIOUS_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE': {
if (store.errorCount === 0 && store.warningCount === 0) {
return state;
}
const elementIndicesWithErrorsOrWarnings = store.getElementsWithErrorsAndWarnings();
let flatIndex = 0;
if (selectedElementIndex !== null) {
// Resume from the current position in the list.
// Otherwise step to the previous item, relative to the current selection.
for (
let i = elementIndicesWithErrorsOrWarnings.length - 1;
i >= 0;
i--
) {
const {index} = elementIndicesWithErrorsOrWarnings[i];
if (index >= selectedElementIndex) {
flatIndex = i;
} else {
break;
}
}
}
let prevEntry;
if (flatIndex === 0) {
prevEntry =
elementIndicesWithErrorsOrWarnings[
elementIndicesWithErrorsOrWarnings.length - 1
];
selectedElementID = prevEntry.id;
selectedElementIndex = prevEntry.index;
} else {
prevEntry = elementIndicesWithErrorsOrWarnings[flatIndex - 1];
selectedElementID = prevEntry.id;
selectedElementIndex = prevEntry.index;
}
lookupIDForIndex = false;
break;
}
case 'SELECT_NEXT_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE': {
if (store.errorCount === 0 && store.warningCount === 0) {
return state;
}
const elementIndicesWithErrorsOrWarnings = store.getElementsWithErrorsAndWarnings();
let flatIndex = -1;
if (selectedElementIndex !== null) {
// Resume from the current position in the list.
// Otherwise step to the next item, relative to the current selection.
for (let i = 0; i < elementIndicesWithErrorsOrWarnings.length; i++) {
const {index} = elementIndicesWithErrorsOrWarnings[i];
if (index <= selectedElementIndex) {
flatIndex = i;
} else {
break;
}
}
}
let nextEntry;
if (flatIndex >= elementIndicesWithErrorsOrWarnings.length - 1) {
nextEntry = elementIndicesWithErrorsOrWarnings[0];
selectedElementID = nextEntry.id;
selectedElementIndex = nextEntry.index;
} else {
nextEntry = elementIndicesWithErrorsOrWarnings[flatIndex + 1];
selectedElementID = nextEntry.id;
selectedElementIndex = nextEntry.index;
}
lookupIDForIndex = false;
break;
}
default:
// React can bailout of no-op updates.
return state;
@@ -776,11 +861,13 @@ function TreeContextController({
case 'SELECT_ELEMENT_BY_ID':
case 'SELECT_CHILD_ELEMENT_IN_TREE':
case 'SELECT_NEXT_ELEMENT_IN_TREE':
case 'SELECT_NEXT_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE':
case 'SELECT_NEXT_SIBLING_IN_TREE':
case 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE':
case 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE':
case 'SELECT_PARENT_ELEMENT_IN_TREE':
case 'SELECT_PREVIOUS_ELEMENT_IN_TREE':
case 'SELECT_PREVIOUS_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE':
case 'SELECT_PREVIOUS_SIBLING_IN_TREE':
case 'SELECT_OWNER':
case 'UPDATE_INSPECTED_ELEMENT_ID':
@@ -84,6 +84,8 @@ export type InspectedElement = {|
props: Object | null,
state: Object | null,
key: number | string | null,
errors: Array<[string, number]>,
warnings: Array<[string, number]>,
// List of owners
owners: Array<Owner> | null,
+13 -1
View File
@@ -16,13 +16,15 @@ export type IconType =
| 'code'
| 'components'
| 'copy'
| 'error'
| 'flame-chart'
| 'interactions'
| 'profiler'
| 'ranked-chart'
| 'search'
| 'settings'
| 'store-as-global-variable';
| 'store-as-global-variable'
| 'warning';
type Props = {|
className?: string,
@@ -47,6 +49,9 @@ export default function Icon({className = '', type}: Props) {
case 'copy':
pathData = PATH_COPY;
break;
case 'error':
pathData = PATH_ERROR;
break;
case 'flame-chart':
pathData = PATH_FLAME_CHART;
break;
@@ -68,6 +73,9 @@ export default function Icon({className = '', type}: Props) {
case 'store-as-global-variable':
pathData = PATH_STORE_AS_GLOBAL_VARIABLE;
break;
case 'warning':
pathData = PATH_WARNING;
break;
default:
console.warn(`Unsupported type "${type}" specified for Icon`);
break;
@@ -107,6 +115,8 @@ const PATH_COPY = `
2v10a2 2 0 0 0 2 2h10c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H9V5h10v10zm-8 6h2v-2h-2v2zm-4 0h2v-2H7v2z
`;
const PATH_ERROR = `M16.971 0h-9.942l-7.029 7.029v9.941l7.029 7.03h9.941l7.03-7.029v-9.942l-7.029-7.029zm-1.402 16.945l-3.554-3.521-3.518 3.568-1.418-1.418 3.507-3.566-3.586-3.472 1.418-1.417 3.581 3.458 3.539-3.583 1.431 1.431-3.535 3.568 3.566 3.522-1.431 1.43z`;
const PATH_FLAME_CHART = `
M10.0650893,21.5040462 C7.14020814,20.6850349 5,18.0558698 5,14.9390244 C5,14.017627
5,9.81707317 7.83333333,7.37804878 C7.83333333,7.37804878 7.58333333,11.199187 10,
@@ -154,3 +164,5 @@ const PATH_STORE_AS_GLOBAL_VARIABLE = `
1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6
8h-4v-2h4v2zm0-4h-4v-2h4v2z
`;
const PATH_WARNING = `M12 1l-12 22h24l-12-22zm-1 8h2v7h-2v-7zm1 11.25c-.69 0-1.25-.56-1.25-1.25s.56-1.25 1.25-1.25 1.25.56 1.25 1.25-.56 1.25-1.25 1.25z`;
@@ -11,8 +11,10 @@ import {
__DEBUG__,
TREE_OPERATION_ADD,
TREE_OPERATION_REMOVE,
TREE_OPERATION_REMOVE_ROOT,
TREE_OPERATION_REORDER_CHILDREN,
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS,
} from 'react-devtools-shared/src/constants';
import {utfDecodeString} from 'react-devtools-shared/src/utils';
import {ElementTypeRoot} from 'react-devtools-shared/src/types';
@@ -294,6 +296,9 @@ function updateTree(
}
break;
}
case TREE_OPERATION_REMOVE_ROOT: {
throw Error('Operation REMOVE_ROOT is not supported while profiling.');
}
case TREE_OPERATION_REORDER_CHILDREN: {
id = ((operations[i + 1]: any): number);
const numChildren = ((operations[i + 2]: any): number);
@@ -329,6 +334,21 @@ function updateTree(
i += 3;
break;
}
case TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS:
id = operations[i + 1];
const numErrors = operations[i + 2];
const numWarnings = operations[i + 3];
i += 4;
if (__DEBUG__) {
debug(
'Warnings and Errors update',
`fiber ${id} has ${numErrors} errors and ${numWarnings} warnings`,
);
}
break;
default:
throw Error(`Unsupported Bridge operation ${operation}`);
}
@@ -19,6 +19,8 @@ export default function DebuggingSettings(_: {||}) {
breakOnConsoleErrors,
setAppendComponentStack,
setBreakOnConsoleErrors,
setShowInlineWarningsAndErrors,
showInlineWarningsAndErrors,
} = useContext(SettingsContext);
return (
@@ -36,6 +38,19 @@ export default function DebuggingSettings(_: {||}) {
</label>
</div>
<div className={styles.Setting}>
<label>
<input
type="checkbox"
checked={showInlineWarningsAndErrors}
onChange={({currentTarget}) =>
setShowInlineWarningsAndErrors(currentTarget.checked)
}
/>{' '}
Show inline warnings and errors.
</label>
</div>
<div className={styles.Setting}>
<label>
<input
@@ -48,6 +63,10 @@ export default function DebuggingSettings(_: {||}) {
Break on warnings
</label>
</div>
<div className={styles.ConsoleAPIWarning}>
These settings require DevTools to override native console APIs.
</div>
</div>
);
}
@@ -21,6 +21,7 @@ import {
LOCAL_STORAGE_SHOULD_BREAK_ON_CONSOLE_ERRORS,
LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY,
LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY,
LOCAL_STORAGE_SHOW_INLINE_WARNINGS_AND_ERRORS_KEY,
} from 'react-devtools-shared/src/constants';
import {useLocalStorage} from '../hooks';
import {BridgeContext} from '../context';
@@ -44,6 +45,9 @@ type Context = {|
breakOnConsoleErrors: boolean,
setBreakOnConsoleErrors: (value: boolean) => void,
showInlineWarningsAndErrors: boolean,
setShowInlineWarningsAndErrors: (value: boolean) => void,
theme: Theme,
setTheme(value: Theme): void,
@@ -90,6 +94,13 @@ function SettingsContextController({
LOCAL_STORAGE_SHOULD_BREAK_ON_CONSOLE_ERRORS,
false,
);
const [
showInlineWarningsAndErrors,
setShowInlineWarningsAndErrors,
] = useLocalStorage<boolean>(
LOCAL_STORAGE_SHOW_INLINE_WARNINGS_AND_ERRORS_KEY,
true,
);
const [
traceUpdatesEnabled,
setTraceUpdatesEnabled,
@@ -147,8 +158,14 @@ function SettingsContextController({
bridge.send('updateConsolePatchSettings', {
appendComponentStack,
breakOnConsoleErrors,
showInlineWarningsAndErrors,
});
}, [bridge, appendComponentStack, breakOnConsoleErrors]);
}, [
bridge,
appendComponentStack,
breakOnConsoleErrors,
showInlineWarningsAndErrors,
]);
useEffect(() => {
bridge.send('setTraceUpdatesEnabled', traceUpdatesEnabled);
@@ -168,6 +185,8 @@ function SettingsContextController({
setDisplayDensity,
setTheme,
setTraceUpdatesEnabled,
setShowInlineWarningsAndErrors,
showInlineWarningsAndErrors,
theme,
traceUpdatesEnabled,
}),
@@ -180,6 +199,8 @@ function SettingsContextController({
setDisplayDensity,
setTheme,
setTraceUpdatesEnabled,
setShowInlineWarningsAndErrors,
showInlineWarningsAndErrors,
theme,
traceUpdatesEnabled,
],
@@ -324,6 +345,25 @@ export function updateThemeVariables(
'color-component-badge-count-inverted',
documentElements,
);
updateStyleHelper(theme, 'color-console-error-badge-text', documentElements);
updateStyleHelper(theme, 'color-console-error-background', documentElements);
updateStyleHelper(theme, 'color-console-error-border', documentElements);
updateStyleHelper(theme, 'color-console-error-icon', documentElements);
updateStyleHelper(theme, 'color-console-error-text', documentElements);
updateStyleHelper(
theme,
'color-console-warning-badge-text',
documentElements,
);
updateStyleHelper(
theme,
'color-console-warning-background',
documentElements,
);
updateStyleHelper(theme, 'color-console-warning-border', documentElements);
updateStyleHelper(theme, 'color-console-warning-icon', documentElements);
updateStyleHelper(theme, 'color-console-warning-text', documentElements);
updateStyleHelper(theme, 'color-context-border', documentElements);
updateStyleHelper(theme, 'color-context-background', documentElements);
updateStyleHelper(theme, 'color-context-background-hover', documentElements);
updateStyleHelper(
@@ -42,4 +42,4 @@
padding: 0.5rem;
flex: 0 1 auto;
overflow: auto;
}
}
@@ -128,7 +128,8 @@
background-color: var(--color-toggle-text);
}
.ReleaseNotes {
.ReleaseNotes,
.ConsoleAPIWarning {
width: 100%;
background-color: var(--color-background-hover);
padding: 0.25rem 0.5rem;
@@ -44,6 +44,16 @@
--light-color-component-badge-background-inverted: rgba(255, 255, 255, 0.25);
--light-color-component-badge-count: #777d88;
--light-color-component-badge-count-inverted: rgba(255, 255, 255, 0.7);
--light-color-console-error-badge-text: #ffffff;
--light-color-console-error-background: #fff0f0;
--light-color-console-error-border: #ffd6d6;
--light-color-console-error-icon: #eb3941;
--light-color-console-error-text: #fe2e31;
--light-color-console-warning-badge-text: #000000;
--light-color-console-warning-background: #fffbe5;
--light-color-console-warning-border: #fff5c1;
--light-color-console-warning-icon: #f4bd00;
--light-color-console-warning-text: #64460c;
--light-color-context-background: rgba(0,0,0,.9);
--light-color-context-background-hover: rgba(255, 255, 255, 0.1);
--light-color-context-background-selected: #178fb9;
@@ -121,6 +131,16 @@
--dark-color-component-badge-background-inverted: rgba(0, 0, 0, 0.25);
--dark-color-component-badge-count: #8f949d;
--dark-color-component-badge-count-inverted: rgba(255, 255, 255, 0.7);
--dark-color-console-error-badge-text: #000000;
--dark-color-console-error-background: #290000;
--dark-color-console-error-border: #5c0000;
--dark-color-console-error-icon: #eb3941;
--dark-color-console-error-text: #fc7f7f;
--dark-color-console-warning-badge-text: #000000;
--dark-color-console-warning-background: #332b00;
--dark-color-console-warning-border: #665500;
--dark-color-console-warning-icon: #f4bd00;
--dark-color-console-warning-text: #f5f2ed;
--dark-color-context-background: rgba(255,255,255,.9);
--dark-color-context-background-hover: rgba(0, 136, 250, 0.1);
--dark-color-context-background-selected: #0088fa;
+3
View File
@@ -182,6 +182,8 @@ export function installHook(target: any): DevToolsHook | null {
window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ !== false;
const breakOnConsoleErrors =
window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ === true;
const showInlineWarningsAndErrors =
window.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ === true;
// The installHook() function is injected by being stringified in the browser,
// so imports outside of this function do not get included.
@@ -195,6 +197,7 @@ export function installHook(target: any): DevToolsHook | null {
patchConsole({
appendComponentStack,
breakOnConsoleErrors,
showInlineWarningsAndErrors,
});
}
} catch (error) {}
+39
View File
@@ -26,7 +26,9 @@ import {REACT_SUSPENSE_LIST_TYPE as SuspenseList} from 'shared/ReactSymbols';
import {
TREE_OPERATION_ADD,
TREE_OPERATION_REMOVE,
TREE_OPERATION_REMOVE_ROOT,
TREE_OPERATION_REORDER_CHILDREN,
TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS,
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
} from './constants';
import {ElementTypeRoot} from 'react-devtools-shared/src/types';
@@ -34,6 +36,7 @@ import {
LOCAL_STORAGE_FILTER_PREFERENCES_KEY,
LOCAL_STORAGE_SHOULD_BREAK_ON_CONSOLE_ERRORS,
LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY,
LOCAL_STORAGE_SHOW_INLINE_WARNINGS_AND_ERRORS_KEY,
} from './constants';
import {ComponentFilterElementType, ElementTypeHostComponent} from './types';
import {
@@ -204,6 +207,12 @@ export function printOperationsArray(operations: Array<number>) {
}
break;
}
case TREE_OPERATION_REMOVE_ROOT: {
i += 1;
logs.push(`Remove root ${rootID}`);
break;
}
case TREE_OPERATION_REORDER_CHILDREN: {
const id = ((operations[i + 1]: any): number);
const numChildren = ((operations[i + 2]: any): number);
@@ -220,6 +229,17 @@ export function printOperationsArray(operations: Array<number>) {
// The profiler UI uses them lazily in order to generate the tree.
i += 3;
break;
case TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS:
const id = operations[i + 1];
const numErrors = operations[i + 2];
const numWarnings = operations[i + 3];
i += 4;
logs.push(
`Node ${id} has ${numErrors} errors and ${numWarnings} warnings`,
);
break;
default:
throw Error(`Unsupported Bridge operation ${operation}`);
}
@@ -293,6 +313,25 @@ export function setBreakOnConsoleErrors(value: boolean): void {
);
}
export function getShowInlineWarningsAndErrors(): boolean {
try {
const raw = localStorageGetItem(
LOCAL_STORAGE_SHOW_INLINE_WARNINGS_AND_ERRORS_KEY,
);
if (raw != null) {
return JSON.parse(raw);
}
} catch (error) {}
return true;
}
export function setShowInlineWarningsAndErrors(value: boolean): void {
localStorageSetItem(
LOCAL_STORAGE_SHOW_INLINE_WARNINGS_AND_ERRORS_KEY,
JSON.stringify(value),
);
}
export function separateDisplayNameAndHOCs(
displayName: string | null,
type: ElementType,
@@ -0,0 +1,181 @@
/** @flow */
import * as React from 'react';
import {Fragment, useEffect, useRef, useState} from 'react';
function WarnDuringRender({children = null}) {
console.warn('This warning fires during every render');
return children;
}
function WarnOnMount({children = null}) {
useEffect(() => {
console.warn('This warning fires on initial mount only');
}, []);
return children;
}
function WarnOnUpdate({children = null}) {
const didMountRef = useRef(false);
useEffect(() => {
if (didMountRef.current) {
console.warn('This warning fires on every update');
} else {
didMountRef.current = true;
}
});
return children;
}
function WarnOnUnmount({children = null}) {
useEffect(() => {
return () => {
console.warn('This warning fires on unmount');
};
}, []);
return children;
}
function ErrorDuringRender({children = null}) {
console.error('This error fires during every render');
return children;
}
function ErrorOnMount({children = null}) {
useEffect(() => {
console.error('This error fires on initial mount only');
}, []);
return children;
}
function ErrorOnUpdate({children = null}) {
const didMountRef = useRef(false);
useEffect(() => {
if (didMountRef.current) {
console.error('This error fires on every update');
} else {
didMountRef.current = true;
}
});
return children;
}
function ErrorOnUnmount({children = null}) {
useEffect(() => {
return () => {
console.error('This error fires on unmount');
};
}, []);
return children;
}
function ErrorAndWarningDuringRender({children = null}) {
console.warn('This warning fires during every render');
console.error('This error fires during every render');
return children;
}
function ErrorAndWarningOnMount({children = null}) {
useEffect(() => {
console.warn('This warning fires on initial mount only');
console.error('This error fires on initial mount only');
}, []);
return children;
}
function ErrorAndWarningOnUpdate({children = null}) {
const didMountRef = useRef(false);
useEffect(() => {
if (didMountRef.current) {
console.warn('This warning fires on every update');
console.error('This error fires on every update');
} else {
didMountRef.current = true;
}
});
return children;
}
function ErrorAndWarningOnUnmount({children = null}) {
useEffect(() => {
return () => {
console.warn('This warning fires on unmount');
console.error('This error fires on unmount');
};
}, []);
return children;
}
function ReallyLongErrorMessageThatWillCauseTextToBeTruncated({
children = null,
}) {
console.error(
'This error is a really long error message that should cause the text to be truncated in DevTools',
);
return children;
}
function ErrorWithMultipleArgs({children = null}) {
console.error('This error', 'passes console', 4, 'arguments');
return children;
}
function ErrorWithStringSubstitutions({children = null}) {
console.error('This error uses "%s" substitutions', 'string');
return children;
}
function ReactErrorOnHostComponent({children = null}) {
return <div data-camelCasedAttribute="should-lower-case">{children}</div>;
}
function DuplicateWarningsAndErrors({children = null}) {
console.warn('this warning is logged twice per render');
console.warn('this warning is logged twice per render');
console.error('this error is logged twice per render');
console.error('this error is logged twice per render');
return <div data-camelCasedAttribute="should-lower-case">{children}</div>;
}
function MultipleWarningsAndErrors({children = null}) {
console.warn('this is the first warning logged');
console.warn('this is the second warning logged');
console.error('this is the first error logged');
console.error('this is the second error logged');
return <div data-camelCasedAttribute="should-lower-case">{children}</div>;
}
function ComponentWithMissingKey({children}) {
return [<div />];
}
export default function ErrorsAndWarnings() {
const [count, setCount] = useState(0);
const handleClick = () => setCount(count + 1);
return (
<Fragment>
<h1>Inline warnings</h1>
<button onClick={handleClick}>Update {count > 0 ? count : ''}</button>
<ComponentWithMissingKey />
<WarnDuringRender />
<WarnOnMount />
<WarnOnUpdate />
{count === 0 ? <WarnOnUnmount /> : null}
{count === 0 ? <WarnOnMount /> : null}
<ErrorDuringRender />
<ErrorOnMount />
<ErrorOnUpdate />
{count === 0 ? <ErrorOnUnmount /> : null}
<ErrorAndWarningDuringRender />
<ErrorAndWarningOnMount />
<ErrorAndWarningOnUpdate />
{count === 0 ? <ErrorAndWarningOnUnmount /> : null}
<ErrorWithMultipleArgs />
<ErrorWithStringSubstitutions />
<ReactErrorOnHostComponent />
<ReallyLongErrorMessageThatWillCauseTextToBeTruncated />
<DuplicateWarningsAndErrors />
<MultipleWarningsAndErrors />
</Fragment>
);
}
+2
View File
@@ -12,6 +12,7 @@ import Iframe from './Iframe';
import EditableProps from './EditableProps';
import ElementTypes from './ElementTypes';
import Hydration from './Hydration';
import InlineWarnings from './InlineWarnings';
import InspectableElements from './InspectableElements';
import InteractionTracing from './InteractionTracing';
import PriorityLevels from './PriorityLevels';
@@ -53,6 +54,7 @@ function mountTestApp() {
mountHelper(Hydration);
mountHelper(ElementTypes);
mountHelper(EditableProps);
mountHelper(InlineWarnings);
mountHelper(PriorityLevels);
mountHelper(ReactNativeWeb);
mountHelper(Toggle);
+30 -1
View File
@@ -141,12 +141,41 @@ While profiling is in progress, we send an extra operation any time a fiber is a
For example, updating the base duration for a fiber with an id of 1:
```js
[
4, // update tree base duration operation
4, // tree base duration operation
1, // fiber id
32, // new tree base duration value
]
```
#### Updating errors and warnings on a Fiber
We record calls to `console.warn` and `console.error` in the backend.
Periodically we notify the frontend that the number of recorded calls got updated.
We only send the serialized messages as part of the `inspectElement` event.
```js
[
5, // update error/warning counts operation
4, // fiber id
0, // number of calls to console.error from that fiber
3, // number of calls to console.warn from that fiber
]
```
#### Removing a root
Special case of unmounting an entire root (include its decsendants). This specialized message replaces what would otherwise be a series of remove-node operations. It is currently only used in one case: updating component filters. The primary motivation for this is actually to preserve fiber ids for components that are re-added to the tree after the updated filters have been applied. This preserves mappings between the Fiber (id) and things like error and warning logs.
```js
[
6, // remove root operation
]
```
This operation has no additional payload because renderer and root ids are already sent at the beginning of every operations payload.
## Reconstructing the tree
The frontend stores its information about the tree in a map of id to objects with the following keys:
@@ -268,4 +297,4 @@ Once profiling is finished, the frontend requests profiling data from the backen
### Importing/exporting data
Because all of the data is merged in the frontend after a profiling session is completed, it can be exported and imported (as a single JSON object), enabling profiling sessions to be shared between users.
Because all of the data is merged in the frontend after a profiling session is completed, it can be exported and imported (as a single JSON object), enabling profiling sessions to be shared between users.
+3
View File
@@ -59,6 +59,9 @@ module.exports = Object.assign({}, baseConfig, {
require.resolve(
'../../packages/react-devtools-shared/src/__tests__/storeSerializer.js'
),
require.resolve(
'../../packages/react-devtools-shared/src/__tests__/treeContextStateSerializer.js'
),
],
setupFiles: [
...baseConfig.setupFiles,