diff --git a/packages/react-devtools-core/src/standalone.js b/packages/react-devtools-core/src/standalone.js index 817b34386c205..e31b43d724d1b 100644 --- a/packages/react-devtools-core/src/standalone.js +++ b/packages/react-devtools-core/src/standalone.js @@ -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( diff --git a/packages/react-devtools-extensions/src/main.js b/packages/react-devtools-extensions/src/main.js index 00e3735c72c11..d8789e1c69afc 100644 --- a/packages/react-devtools-extensions/src/main.js +++ b/packages/react-devtools-extensions/src/main.js @@ -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(), )};`, ); } diff --git a/packages/react-devtools-inline/src/backend.js b/packages/react-devtools-inline/src/backend.js index fb46276924652..7a5bd57b928eb 100644 --- a/packages/react-devtools-inline/src/backend.js +++ b/packages/react-devtools-inline/src/backend.js @@ -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); diff --git a/packages/react-devtools-inline/src/frontend.js b/packages/react-devtools-inline/src/frontend.js index 9a5c145471139..3972f1789ab74 100644 --- a/packages/react-devtools-inline/src/frontend.js +++ b/packages/react-devtools-inline/src/frontend.js @@ -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(), }, '*', ); diff --git a/packages/react-devtools-shared/src/__tests__/__snapshots__/ownersListContext-test.js.snap b/packages/react-devtools-shared/src/__tests__/__snapshots__/ownersListContext-test.js.snap index 25e56daf6f44e..d5e244adf062f 100644 --- a/packages/react-devtools-shared/src/__tests__/__snapshots__/ownersListContext-test.js.snap +++ b/packages/react-devtools-shared/src/__tests__/__snapshots__/ownersListContext-test.js.snap @@ -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, }, ] diff --git a/packages/react-devtools-shared/src/__tests__/__snapshots__/storeComponentFilters-test.js.snap b/packages/react-devtools-shared/src/__tests__/__snapshots__/storeComponentFilters-test.js.snap deleted file mode 100644 index 49035b9927836..0000000000000 --- a/packages/react-devtools-shared/src/__tests__/__snapshots__/storeComponentFilters-test.js.snap +++ /dev/null @@ -1,140 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Store component filters should filter HOCs: 1: mount 1`] = ` -[root] - ▾ [Bar][Foo] - ▾ [Foo] - ▾ -
-`; - -exports[`Store component filters should filter HOCs: 2: hide all HOCs 1`] = ` -[root] - ▾ -
-`; - -exports[`Store component filters should filter HOCs: 3: disable HOC filter 1`] = ` -[root] - ▾ [Bar][Foo] - ▾ [Foo] - ▾ -
-`; - -exports[`Store component filters should filter by display name: 1: mount 1`] = ` -[root] - ▾ - - ▾ - - ▾ - -`; - -exports[`Store component filters should filter by display name: 2: filter "Foo" 1`] = ` -[root] - - ▾ - - ▾ - -`; - -exports[`Store component filters should filter by display name: 3: filter "Ba" 1`] = ` -[root] - ▾ - - - -`; - -exports[`Store component filters should filter by display name: 4: filter "B.z" 1`] = ` -[root] - ▾ - - ▾ - - -`; - -exports[`Store component filters should filter by path: 1: mount 1`] = ` -[root] - ▾ -
-`; - -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] - ▾ -
-`; - -exports[`Store component filters should ignore invalid ElementTypeRoot filter: 1: mount 1`] = ` -[root] - ▾ -
-`; - -exports[`Store component filters should ignore invalid ElementTypeRoot filter: 2: add invalid filter 1`] = ` -[root] - ▾ -
-`; - -exports[`Store component filters should not break when Suspense nodes are filtered from the tree: 1: suspended 1`] = ` -[root] - ▾ - ▾ -
-`; - -exports[`Store component filters should not break when Suspense nodes are filtered from the tree: 2: resolved 1`] = ` -[root] - ▾ - -`; - -exports[`Store component filters should not break when Suspense nodes are filtered from the tree: 3: suspended 1`] = ` -[root] - ▾ - ▾ -
-`; - -exports[`Store component filters should support filtering by element type: 1: mount 1`] = ` -[root] - ▾ - ▾
- ▾ -
-`; - -exports[`Store component filters should support filtering by element type: 2: hide host components 1`] = ` -[root] - ▾ - -`; - -exports[`Store component filters should support filtering by element type: 3: hide class components 1`] = ` -[root] - ▾
- ▾ -
-`; - -exports[`Store component filters should support filtering by element type: 4: hide class and function components 1`] = ` -[root] - ▾
-
-`; - -exports[`Store component filters should support filtering by element type: 5: disable all filters 1`] = ` -[root] - ▾ - ▾
- ▾ -
-`; diff --git a/packages/react-devtools-shared/src/__tests__/__snapshots__/treeContext-test.js.snap b/packages/react-devtools-shared/src/__tests__/__snapshots__/treeContext-test.js.snap deleted file mode 100644 index 1e72710e4bd2b..0000000000000 --- a/packages/react-devtools-shared/src/__tests__/__snapshots__/treeContext-test.js.snap +++ /dev/null @@ -1,1293 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`TreeListContext owners state should exit the owners list if an element outside the list is selected: 0: mount 1`] = ` -[root] - ▾ - ▾ - ▾ - ▾ - -`; - -exports[`TreeListContext owners state should exit the owners list if an element outside the list is selected: 1: initial state 1`] = ` -Object { - "inspectedElementID": null, - "numElements": 5, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": null, - "selectedElementIndex": null, -} -`; - -exports[`TreeListContext owners state should exit the owners list if an element outside the list is selected: 2: child owners tree 1`] = ` -Object { - "inspectedElementID": 4, - "numElements": 3, - "ownerFlatTree": Array [ - Object { - "children": Array [ - 5, - ], - "depth": 0, - "displayName": "Child", - "hocDisplayNames": null, - "id": 4, - "isCollapsed": false, - "key": null, - "ownerID": 2, - "parentID": 3, - "type": 5, - "weight": 3, - }, - Object { - "children": Array [ - 6, - ], - "depth": 1, - "displayName": "Suspense", - "hocDisplayNames": null, - "id": 5, - "isCollapsed": false, - "key": null, - "ownerID": 4, - "parentID": 4, - "type": 12, - "weight": 2, - }, - Object { - "children": Array [], - "depth": 2, - "displayName": "Grandchild", - "hocDisplayNames": null, - "id": 6, - "isCollapsed": false, - "key": null, - "ownerID": 4, - "parentID": 5, - "type": 5, - "weight": 1, - }, - ], - "ownerID": 4, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 4, - "selectedElementIndex": 0, -} -`; - -exports[`TreeListContext owners state should exit the owners list if an element outside the list is selected: 3: child owners tree 1`] = ` -Object { - "inspectedElementID": 5, - "numElements": 3, - "ownerFlatTree": Array [ - Object { - "children": Array [ - 5, - ], - "depth": 0, - "displayName": "Child", - "hocDisplayNames": null, - "id": 4, - "isCollapsed": false, - "key": null, - "ownerID": 2, - "parentID": 3, - "type": 5, - "weight": 3, - }, - Object { - "children": Array [ - 6, - ], - "depth": 1, - "displayName": "Suspense", - "hocDisplayNames": null, - "id": 5, - "isCollapsed": false, - "key": null, - "ownerID": 4, - "parentID": 4, - "type": 12, - "weight": 2, - }, - Object { - "children": Array [], - "depth": 2, - "displayName": "Grandchild", - "hocDisplayNames": null, - "id": 6, - "isCollapsed": false, - "key": null, - "ownerID": 4, - "parentID": 5, - "type": 5, - "weight": 1, - }, - ], - "ownerID": 4, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 5, - "selectedElementIndex": 1, -} -`; - -exports[`TreeListContext owners state should exit the owners list if an element outside the list is selected: 4: main tree 1`] = ` -Object { - "inspectedElementID": 5, - "numElements": 5, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 5, - "selectedElementIndex": 1, -} -`; - -exports[`TreeListContext owners state should exit the owners list if the current owner is unmounted: 0: mount 1`] = ` -[root] - ▾ - -`; - -exports[`TreeListContext owners state should exit the owners list if the current owner is unmounted: 1: initial state 1`] = ` -Object { - "inspectedElementID": null, - "numElements": 2, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": null, - "selectedElementIndex": null, -} -`; - -exports[`TreeListContext owners state should exit the owners list if the current owner is unmounted: 2: child owners tree 1`] = ` -Object { - "inspectedElementID": 3, - "numElements": 1, - "ownerFlatTree": Array [ - Object { - "children": Array [], - "depth": 0, - "displayName": "Child", - "hocDisplayNames": null, - "id": 3, - "isCollapsed": false, - "key": null, - "ownerID": 0, - "parentID": 2, - "type": 5, - "weight": 1, - }, - ], - "ownerID": 3, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 3, - "selectedElementIndex": 0, -} -`; - -exports[`TreeListContext owners state should exit the owners list if the current owner is unmounted: 3: remove child 1`] = ` -Object { - "inspectedElementID": null, - "numElements": 1, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": null, - "selectedElementIndex": 0, -} -`; - -exports[`TreeListContext owners state should exit the owners list if the current owner is unmounted: 4: parent owners tree 1`] = ` -Object { - "inspectedElementID": 2, - "numElements": 1, - "ownerFlatTree": Array [ - Object { - "children": Array [], - "depth": 0, - "displayName": "Parent", - "hocDisplayNames": null, - "id": 2, - "isCollapsed": false, - "key": null, - "ownerID": 0, - "parentID": 1, - "type": 5, - "weight": 1, - }, - ], - "ownerID": 2, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 2, - "selectedElementIndex": 0, -} -`; - -exports[`TreeListContext owners state should exit the owners list if the current owner is unmounted: 5: unmount root 1`] = ` -Object { - "inspectedElementID": null, - "numElements": 0, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": null, - "selectedElementIndex": 0, -} -`; - -exports[`TreeListContext owners state should remove an element from the owners list if it is unmounted: 0: mount 1`] = ` -[root] - ▾ - ▾ - - -`; - -exports[`TreeListContext owners state should remove an element from the owners list if it is unmounted: 1: initial state 1`] = ` -Object { - "inspectedElementID": null, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": null, - "selectedElementIndex": null, -} -`; - -exports[`TreeListContext owners state should remove an element from the owners list if it is unmounted: 2: parent owners tree 1`] = ` -Object { - "inspectedElementID": 3, - "numElements": 3, - "ownerFlatTree": Array [ - Object { - "children": Array [ - 4, - 5, - ], - "depth": 0, - "displayName": "Parent", - "hocDisplayNames": null, - "id": 3, - "isCollapsed": false, - "key": null, - "ownerID": 2, - "parentID": 2, - "type": 5, - "weight": 3, - }, - Object { - "children": Array [], - "depth": 1, - "displayName": "Child", - "hocDisplayNames": null, - "id": 4, - "isCollapsed": false, - "key": "0", - "ownerID": 3, - "parentID": 3, - "type": 5, - "weight": 1, - }, - Object { - "children": Array [], - "depth": 1, - "displayName": "Child", - "hocDisplayNames": null, - "id": 5, - "isCollapsed": false, - "key": "1", - "ownerID": 3, - "parentID": 3, - "type": 5, - "weight": 1, - }, - ], - "ownerID": 3, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 3, - "selectedElementIndex": 0, -} -`; - -exports[`TreeListContext owners state should remove an element from the owners list if it is unmounted: 3: remove second child 1`] = ` -Object { - "inspectedElementID": 3, - "numElements": 2, - "ownerFlatTree": Array [ - Object { - "children": Array [ - 4, - ], - "depth": 0, - "displayName": "Parent", - "hocDisplayNames": null, - "id": 3, - "isCollapsed": false, - "key": null, - "ownerID": 2, - "parentID": 2, - "type": 5, - "weight": 2, - }, - Object { - "children": Array [], - "depth": 1, - "displayName": "Child", - "hocDisplayNames": null, - "id": 4, - "isCollapsed": false, - "key": "0", - "ownerID": 3, - "parentID": 3, - "type": 5, - "weight": 1, - }, - ], - "ownerID": 3, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 3, - "selectedElementIndex": 0, -} -`; - -exports[`TreeListContext owners state should remove an element from the owners list if it is unmounted: 4: remove first child 1`] = ` -Object { - "inspectedElementID": 3, - "numElements": 1, - "ownerFlatTree": Array [ - Object { - "children": Array [], - "depth": 0, - "displayName": "Parent", - "hocDisplayNames": null, - "id": 3, - "isCollapsed": false, - "key": null, - "ownerID": 2, - "parentID": 2, - "type": 5, - "weight": 1, - }, - ], - "ownerID": 3, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 3, - "selectedElementIndex": 0, -} -`; - -exports[`TreeListContext owners state should support entering and existing the owners tree view: 0: mount 1`] = ` -[root] - ▾ - ▾ - - -`; - -exports[`TreeListContext owners state should support entering and existing the owners tree view: 1: initial state 1`] = ` -Object { - "inspectedElementID": null, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": null, - "selectedElementIndex": null, -} -`; - -exports[`TreeListContext owners state should support entering and existing the owners tree view: 2: parent owners tree 1`] = ` -Object { - "inspectedElementID": 3, - "numElements": 3, - "ownerFlatTree": Array [ - Object { - "children": Array [ - 4, - 5, - ], - "depth": 0, - "displayName": "Parent", - "hocDisplayNames": null, - "id": 3, - "isCollapsed": false, - "key": null, - "ownerID": 2, - "parentID": 2, - "type": 5, - "weight": 3, - }, - Object { - "children": Array [], - "depth": 1, - "displayName": "Child", - "hocDisplayNames": null, - "id": 4, - "isCollapsed": false, - "key": null, - "ownerID": 3, - "parentID": 3, - "type": 5, - "weight": 1, - }, - Object { - "children": Array [], - "depth": 1, - "displayName": "Child", - "hocDisplayNames": null, - "id": 5, - "isCollapsed": false, - "key": null, - "ownerID": 3, - "parentID": 3, - "type": 5, - "weight": 1, - }, - ], - "ownerID": 3, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 3, - "selectedElementIndex": 0, -} -`; - -exports[`TreeListContext owners state should support entering and existing the owners tree view: 3: final state 1`] = ` -Object { - "inspectedElementID": 3, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 3, - "selectedElementIndex": 1, -} -`; - -exports[`TreeListContext search state should add newly mounted elements to the search results set if they match the current text: 0: mount 1`] = ` -[root] - - -`; - -exports[`TreeListContext search state should add newly mounted elements to the search results set if they match the current text: 1: initial state 1`] = ` -Object { - "inspectedElementID": null, - "numElements": 2, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": null, - "selectedElementIndex": null, -} -`; - -exports[`TreeListContext search state should add newly mounted elements to the search results set if they match the current text: 2: search for "ba" 1`] = ` -Object { - "inspectedElementID": 3, - "numElements": 2, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": 0, - "searchResults": Array [ - 3, - ], - "searchText": "ba", - "selectedElementID": 3, - "selectedElementIndex": 1, -} -`; - -exports[`TreeListContext search state should add newly mounted elements to the search results set if they match the current text: 3: mount Baz 1`] = ` -Object { - "inspectedElementID": 3, - "numElements": 3, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": 0, - "searchResults": Array [ - 3, - 4, - ], - "searchText": "ba", - "selectedElementID": 3, - "selectedElementIndex": 1, -} -`; - -exports[`TreeListContext search state should find elements matching search text: 0: mount 1`] = ` -[root] - - - - [withHOC] -`; - -exports[`TreeListContext search state should find elements matching search text: 1: initial state 1`] = ` -Object { - "inspectedElementID": null, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": null, - "selectedElementIndex": null, -} -`; - -exports[`TreeListContext search state should find elements matching search text: 2: search for "ba" 1`] = ` -Object { - "inspectedElementID": 3, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": 0, - "searchResults": Array [ - 3, - 4, - ], - "searchText": "ba", - "selectedElementID": 3, - "selectedElementIndex": 1, -} -`; - -exports[`TreeListContext search state should find elements matching search text: 3: search for "f" 1`] = ` -Object { - "inspectedElementID": 2, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": 0, - "searchResults": Array [ - 2, - ], - "searchText": "f", - "selectedElementID": 2, - "selectedElementIndex": 0, -} -`; - -exports[`TreeListContext search state should find elements matching search text: 4: search for "y" 1`] = ` -Object { - "inspectedElementID": 2, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "y", - "selectedElementID": 2, - "selectedElementIndex": 0, -} -`; - -exports[`TreeListContext search state should find elements matching search text: 5: search for "w" 1`] = ` -Object { - "inspectedElementID": 5, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": 0, - "searchResults": Array [ - 5, - ], - "searchText": "w", - "selectedElementID": 5, - "selectedElementIndex": 3, -} -`; - -exports[`TreeListContext search state should remove unmounted elements from the search results set: 0: mount 1`] = ` -[root] - - - -`; - -exports[`TreeListContext search state should remove unmounted elements from the search results set: 1: initial state 1`] = ` -Object { - "inspectedElementID": null, - "numElements": 3, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": null, - "selectedElementIndex": null, -} -`; - -exports[`TreeListContext search state should remove unmounted elements from the search results set: 2: search for "ba" 1`] = ` -Object { - "inspectedElementID": 3, - "numElements": 3, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": 0, - "searchResults": Array [ - 3, - 4, - ], - "searchText": "ba", - "selectedElementID": 3, - "selectedElementIndex": 1, -} -`; - -exports[`TreeListContext search state should remove unmounted elements from the search results set: 3: go to second result 1`] = ` -Object { - "inspectedElementID": 4, - "numElements": 3, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": 1, - "searchResults": Array [ - 3, - 4, - ], - "searchText": "ba", - "selectedElementID": 4, - "selectedElementIndex": 2, -} -`; - -exports[`TreeListContext search state should remove unmounted elements from the search results set: 4: unmount Baz 1`] = ` -Object { - "inspectedElementID": null, - "numElements": 2, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": 0, - "searchResults": Array [ - 3, - ], - "searchText": "ba", - "selectedElementID": null, - "selectedElementIndex": null, -} -`; - -exports[`TreeListContext search state should select the next and previous items within the search results: 0: mount 1`] = ` -[root] - - - - -`; - -exports[`TreeListContext search state should select the next and previous items within the search results: 1: initial state 1`] = ` -Object { - "inspectedElementID": null, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": null, - "selectedElementIndex": null, -} -`; - -exports[`TreeListContext search state should select the next and previous items within the search results: 2: search for "ba" 1`] = ` -Object { - "inspectedElementID": 3, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": 0, - "searchResults": Array [ - 3, - 4, - 5, - ], - "searchText": "ba", - "selectedElementID": 3, - "selectedElementIndex": 1, -} -`; - -exports[`TreeListContext search state should select the next and previous items within the search results: 3: go to second result 1`] = ` -Object { - "inspectedElementID": 4, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": 1, - "searchResults": Array [ - 3, - 4, - 5, - ], - "searchText": "ba", - "selectedElementID": 4, - "selectedElementIndex": 2, -} -`; - -exports[`TreeListContext search state should select the next and previous items within the search results: 4: go to third result 1`] = ` -Object { - "inspectedElementID": 5, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": 2, - "searchResults": Array [ - 3, - 4, - 5, - ], - "searchText": "ba", - "selectedElementID": 5, - "selectedElementIndex": 3, -} -`; - -exports[`TreeListContext search state should select the next and previous items within the search results: 5: go to second result 1`] = ` -Object { - "inspectedElementID": 4, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": 1, - "searchResults": Array [ - 3, - 4, - 5, - ], - "searchText": "ba", - "selectedElementID": 4, - "selectedElementIndex": 2, -} -`; - -exports[`TreeListContext search state should select the next and previous items within the search results: 6: go to first result 1`] = ` -Object { - "inspectedElementID": 3, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": 0, - "searchResults": Array [ - 3, - 4, - 5, - ], - "searchText": "ba", - "selectedElementID": 3, - "selectedElementIndex": 1, -} -`; - -exports[`TreeListContext search state should select the next and previous items within the search results: 7: wrap to last result 1`] = ` -Object { - "inspectedElementID": 5, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": 2, - "searchResults": Array [ - 3, - 4, - 5, - ], - "searchText": "ba", - "selectedElementID": 5, - "selectedElementIndex": 3, -} -`; - -exports[`TreeListContext search state should select the next and previous items within the search results: 8: wrap to first result 1`] = ` -Object { - "inspectedElementID": 3, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": 0, - "searchResults": Array [ - 3, - 4, - 5, - ], - "searchText": "ba", - "selectedElementID": 3, - "selectedElementIndex": 1, -} -`; - -exports[`TreeListContext tree state should clear selection if the selected element is unmounted: 0: mount 1`] = ` -[root] - ▾ - ▾ - - -`; - -exports[`TreeListContext tree state should clear selection if the selected element is unmounted: 1: initial state 1`] = ` -Object { - "inspectedElementID": null, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": null, - "selectedElementIndex": null, -} -`; - -exports[`TreeListContext tree state should clear selection if the selected element is unmounted: 2: select second child 1`] = ` -Object { - "inspectedElementID": 5, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 5, - "selectedElementIndex": 3, -} -`; - -exports[`TreeListContext tree state should clear selection if the selected element is unmounted: 3: remove children (parent should now be selected) 1`] = ` -Object { - "inspectedElementID": 3, - "numElements": 2, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 3, - "selectedElementIndex": 1, -} -`; - -exports[`TreeListContext tree state should clear selection if the selected element is unmounted: 4: unmount root (nothing should be selected) 1`] = ` -Object { - "inspectedElementID": null, - "numElements": 0, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": null, - "selectedElementIndex": null, -} -`; - -exports[`TreeListContext tree state should navigate next/previous sibling and skip over children in between: 0: mount 1`] = ` -[root] - ▾ - ▾ - - ▾ - - - - ▾ - - -`; - -exports[`TreeListContext tree state should navigate the owner hierarchy: 0: mount 1`] = ` -[root] - ▾ - ▾ - ▾ - - ▾ - ▾ - - - - ▾ - ▾ - - -`; - -exports[`TreeListContext tree state should select child elements: 0: mount 1`] = ` -[root] - ▾ - ▾ - - - ▾ - - -`; - -exports[`TreeListContext tree state should select child elements: 1: initial state 1`] = ` -Object { - "inspectedElementID": null, - "numElements": 7, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": null, - "selectedElementIndex": null, -} -`; - -exports[`TreeListContext tree state should select child elements: 2: select first element 1`] = ` -Object { - "inspectedElementID": 2, - "numElements": 7, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 2, - "selectedElementIndex": 0, -} -`; - -exports[`TreeListContext tree state should select child elements: 3: select Parent 1`] = ` -Object { - "inspectedElementID": 3, - "numElements": 7, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 3, - "selectedElementIndex": 1, -} -`; - -exports[`TreeListContext tree state should select child elements: 4: select Child 1`] = ` -Object { - "inspectedElementID": 4, - "numElements": 7, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 4, - "selectedElementIndex": 2, -} -`; - -exports[`TreeListContext tree state should select parent elements and then collapse: 0: mount 1`] = ` -[root] - ▾ - ▾ - - - ▾ - - -`; - -exports[`TreeListContext tree state should select parent elements and then collapse: 1: initial state 1`] = ` -Object { - "inspectedElementID": null, - "numElements": 7, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": null, - "selectedElementIndex": null, -} -`; - -exports[`TreeListContext tree state should select parent elements and then collapse: 2: select last child 1`] = ` -Object { - "inspectedElementID": 8, - "numElements": 7, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 8, - "selectedElementIndex": 6, -} -`; - -exports[`TreeListContext tree state should select parent elements and then collapse: 3: select Parent 1`] = ` -Object { - "inspectedElementID": 6, - "numElements": 7, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 6, - "selectedElementIndex": 4, -} -`; - -exports[`TreeListContext tree state should select parent elements and then collapse: 4: select Grandparent 1`] = ` -Object { - "inspectedElementID": 2, - "numElements": 7, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 2, - "selectedElementIndex": 0, -} -`; - -exports[`TreeListContext tree state should select the next and previous elements in the tree: 0: mount 1`] = ` -[root] - ▾ - ▾ - - -`; - -exports[`TreeListContext tree state should select the next and previous elements in the tree: 1: initial state 1`] = ` -Object { - "inspectedElementID": null, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": null, - "selectedElementIndex": null, -} -`; - -exports[`TreeListContext tree state should select the next and previous elements in the tree: 2: select first element 1`] = ` -Object { - "inspectedElementID": 2, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 2, - "selectedElementIndex": 0, -} -`; - -exports[`TreeListContext tree state should select the next and previous elements in the tree: 3: select element after (0) 1`] = ` -Object { - "inspectedElementID": 3, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 3, - "selectedElementIndex": 1, -} -`; - -exports[`TreeListContext tree state should select the next and previous elements in the tree: 3: select element after (1) 1`] = ` -Object { - "inspectedElementID": 4, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 4, - "selectedElementIndex": 2, -} -`; - -exports[`TreeListContext tree state should select the next and previous elements in the tree: 3: select element after (2) 1`] = ` -Object { - "inspectedElementID": 5, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 5, - "selectedElementIndex": 3, -} -`; - -exports[`TreeListContext tree state should select the next and previous elements in the tree: 4: select element before (1) 1`] = ` -Object { - "inspectedElementID": 2, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 2, - "selectedElementIndex": 0, -} -`; - -exports[`TreeListContext tree state should select the next and previous elements in the tree: 4: select element before (2) 1`] = ` -Object { - "inspectedElementID": 3, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 3, - "selectedElementIndex": 1, -} -`; - -exports[`TreeListContext tree state should select the next and previous elements in the tree: 4: select element before (3) 1`] = ` -Object { - "inspectedElementID": 4, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 4, - "selectedElementIndex": 2, -} -`; - -exports[`TreeListContext tree state should select the next and previous elements in the tree: 5: select previous wraps around to last 1`] = ` -Object { - "inspectedElementID": 5, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 5, - "selectedElementIndex": 3, -} -`; - -exports[`TreeListContext tree state should select the next and previous elements in the tree: 6: select next wraps around to first 1`] = ` -Object { - "inspectedElementID": 2, - "numElements": 4, - "ownerFlatTree": null, - "ownerID": null, - "ownerSubtreeLeafElementID": null, - "searchIndex": null, - "searchResults": Array [], - "searchText": "", - "selectedElementID": 2, - "selectedElementIndex": 0, -} -`; diff --git a/packages/react-devtools-shared/src/__tests__/inspectedElementContext-test.js b/packages/react-devtools-shared/src/__tests__/inspectedElementContext-test.js index f08a3d9d68074..153bb43801ba2 100644 --- a/packages/react-devtools-shared/src/__tests__/inspectedElementContext-test.js +++ b/packages/react-devtools-shared/src/__tests__/inspectedElementContext-test.js @@ -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( + + + + + , + ); + }, 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(, 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(, 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(, 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(, 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 [
]; + }; + + 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(, 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(, 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( + + + + , + 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( + + + + , + 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, + ], + ], + }, + ] + `); + }); + }); }); diff --git a/packages/react-devtools-shared/src/__tests__/profilerStore-test.js b/packages/react-devtools-shared/src/__tests__/profilerStore-test.js index 3a6a78597935a..dca154ad170c6 100644 --- a/packages/react-devtools-shared/src/__tests__/profilerStore-test.js +++ b/packages/react-devtools-shared/src/__tests__/profilerStore-test.js @@ -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'); + }); }); diff --git a/packages/react-devtools-shared/src/__tests__/setupTests.js b/packages/react-devtools-shared/src/__tests__/setupTests.js index e1f7f13f76b2c..b244c7e0cf17e 100644 --- a/packages/react-devtools-shared/src/__tests__/setupTests.js +++ b/packages/react-devtools-shared/src/__tests__/setupTests.js @@ -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 = []; diff --git a/packages/react-devtools-shared/src/__tests__/store-test.js b/packages/react-devtools-shared/src/__tests__/store-test.js index 92face2f37655..33d3bc0a072a3 100644 --- a/packages/react-devtools-shared/src/__tests__/store-test.js +++ b/packages/react-devtools-shared/src/__tests__/store-test.js @@ -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(, container)); + }); + + expect(store).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + ✕⚠ + `); + + withErrorsOrWarningsIgnored(['test-only:'], () => { + act(() => ReactDOM.render(, container)); + }); + + expect(store).toMatchInlineSnapshot(` + ✕ 2, ⚠ 2 + [root] + ✕⚠ + `); + }); + + 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(, container)); + }); + + expect(store).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + ✕⚠ + `); + + withErrorsOrWarningsIgnored(['test-only:'], () => { + act(() => ReactDOM.render(, container)); + }); + + expect(store).toMatchInlineSnapshot(` + ✕ 2, ⚠ 2 + [root] + ✕⚠ + `); + }); + + // 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(, container)); + }); + + expect(store).toMatchInlineSnapshot(` + [root] + + `); + + withErrorsOrWarningsIgnored(['test-only:'], () => { + act(() => ReactDOM.render(, container)); + }); + + expect(store).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + ✕⚠ + `); + + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(store).toMatchInlineSnapshot(``); + }); + + it('from react get counted', () => { + const container = document.createElement('div'); + function Example() { + return []; + } + function Child() { + return null; + } + + withErrorsOrWarningsIgnored( + ['Warning: Each child in a list should have a unique "key" prop'], + () => { + act(() => ReactDOM.render(, container)); + }, + ); + + expect(store).toMatchInlineSnapshot(` + ✕ 1, ⚠ 0 + [root] + ▾ ✕ + + `); + }); + + 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( + + + + , + container, + ), + ); + }); + + expect(store).toMatchInlineSnapshot(` + ✕ 2, ⚠ 2 + [root] + ✕⚠ + ✕⚠ + `); + + store.clearErrorsAndWarnings(); + // flush events to the renderer + jest.runAllTimers(); + + expect(store).toMatchInlineSnapshot(` + [root] + + + `); + }); + + 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( + + + + , + container, + ), + ); + }); + + expect(store).toMatchInlineSnapshot(` + ✕ 2, ⚠ 2 + [root] + ✕⚠ + ✕⚠ + `); + + store.clearWarningsForElement(2); + // Flush events to the renderer. + jest.runAllTimers(); + + expect(store).toMatchInlineSnapshot(` + ✕ 2, ⚠ 1 + [root] + ✕⚠ + ✕ + `); + }); + + 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( + + + + , + container, + ), + ); + }); + + expect(store).toMatchInlineSnapshot(` + ✕ 2, ⚠ 2 + [root] + ✕⚠ + ✕⚠ + `); + + store.clearErrorsForElement(2); + // Flush events to the renderer. + jest.runAllTimers(); + + expect(store).toMatchInlineSnapshot(` + ✕ 1, ⚠ 2 + [root] + ✕⚠ + ⚠ + `); + }); + + 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( + + + + + , + container, + ), + ); + }); + expect(store).toMatchInlineSnapshot(` + ✕ 2, ⚠ 2 + [root] + ✕ + ⚠ + ✕⚠ + `); + + withErrorsOrWarningsIgnored(['test-only:'], () => { + act(() => + ReactDOM.render( + + + + , + container, + ), + ); + }); + expect(store).toMatchInlineSnapshot(` + ✕ 1, ⚠ 2 + [root] + ⚠ + ✕⚠ + `); + + withErrorsOrWarningsIgnored(['test-only:'], () => { + act(() => + ReactDOM.render( + + + , + container, + ), + ); + }); + expect(store).toMatchInlineSnapshot(` + ✕ 0, ⚠ 2 + [root] + ⚠ + `); + + withErrorsOrWarningsIgnored(['test-only:'], () => { + act(() => ReactDOM.render(, container)); + }); + expect(store).toMatchInlineSnapshot(`[root]`); + expect(store.errorCount).toBe(0); + expect(store.warningCount).toBe(0); + }); + }); }); diff --git a/packages/react-devtools-shared/src/__tests__/storeComponentFilters-test.js b/packages/react-devtools-shared/src/__tests__/storeComponentFilters-test.js index a8a0e151ef748..4a365acb0ef3c 100644 --- a/packages/react-devtools-shared/src/__tests__/storeComponentFilters-test.js +++ b/packages/react-devtools-shared/src/__tests__/storeComponentFilters-test.js @@ -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
{this.props.children}
; } } - const Component = () =>
Hi
; + const FunctionComponent = () =>
Hi
; act(() => ReactDOM.render( - - - , + + + , document.createElement('div'), ), ); - expect(store).toMatchSnapshot('1: mount'); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + ▾
+ ▾ +
+ `); act( () => @@ -71,8 +77,11 @@ describe('Store component filters', () => { utils.createElementTypeFilter(Types.ElementTypeHostComponent), ]), ); - - expect(store).toMatchSnapshot('2: hide host components'); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + + `); act( () => @@ -80,8 +89,12 @@ describe('Store component filters', () => { utils.createElementTypeFilter(Types.ElementTypeClass), ]), ); - - expect(store).toMatchSnapshot('3: hide class components'); + expect(store).toMatchInlineSnapshot(` + [root] + ▾
+ ▾ +
+ `); 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] + ▾
+
+ `); act( () => @@ -100,15 +116,33 @@ describe('Store component filters', () => { utils.createElementTypeFilter(Types.ElementTypeFunction, false), ]), ); - - expect(store).toMatchSnapshot('5: disable all filters'); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + ▾
+ ▾ +
+ `); + + act(() => (store.componentFilters = [])); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + ▾
+ ▾ +
+ `); }); it('should ignore invalid ElementTypeRoot filter', () => { - const Root = () =>
Hi
; + const Component = () =>
Hi
; - act(() => ReactDOM.render(, document.createElement('div'))); - expect(store).toMatchSnapshot('1: mount'); + act(() => ReactDOM.render(, document.createElement('div'))); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ +
+ `); act( () => @@ -117,7 +151,11 @@ describe('Store component filters', () => { ]), ); - expect(store).toMatchSnapshot('2: add invalid filter'); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ +
+ `); }); 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] + ▾ + + ▾ + + ▾ + + `); act( () => (store.componentFilters = [utils.createDisplayNameFilter('Foo')]), ); - expect(store).toMatchSnapshot('2: filter "Foo"'); + expect(store).toMatchInlineSnapshot(` + [root] + + ▾ + + ▾ + + `); act(() => (store.componentFilters = [utils.createDisplayNameFilter('Ba')])); - expect(store).toMatchSnapshot('3: filter "Ba"'); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + + + + `); act( () => (store.componentFilters = [utils.createDisplayNameFilter('B.z')]), ); - expect(store).toMatchSnapshot('4: filter "B.z"'); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + + ▾ + + + `); }); it('should filter by path', () => { const Component = () =>
Hi
; act(() => ReactDOM.render(, document.createElement('div'))); - expect(store).toMatchSnapshot('1: mount'); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ +
+ `); 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] + ▾ +
+ `); }); it('should filter HOCs', () => { @@ -187,15 +259,29 @@ describe('Store component filters', () => { Bar.displayName = 'Bar(Foo(Component))'; act(() => ReactDOM.render(, document.createElement('div'))); - expect(store).toMatchSnapshot('1: mount'); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ [Bar][Foo] + ▾ [Foo] + ▾ +
+ `); act(() => (store.componentFilters = [utils.createHOCFilter(true)])); - - expect(store).toMatchSnapshot('2: hide all HOCs'); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ +
+ `); act(() => (store.componentFilters = [utils.createHOCFilter(false)])); - - expect(store).toMatchSnapshot('3: disable HOC filter'); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ [Bar][Foo] + ▾ [Foo] + ▾ +
+ `); }); 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(, container)); - expect(store).toMatchSnapshot('1: suspended'); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + ▾ +
+ `); act(() => ReactDOM.render(, container)); - expect(store).toMatchSnapshot('2: resolved'); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + + `); act(() => ReactDOM.render(, container)); - expect(store).toMatchSnapshot('3: suspended'); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + ▾ +
+ `); + }); + + 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( + + + + + , + container, + ), + ); + }); + + expect(store).toMatchInlineSnapshot(`[root]`); + expect(store.errorCount).toBe(0); + expect(store.warningCount).toBe(0); + + act(() => (store.componentFilters = [])); + expect(store).toMatchInlineSnapshot(` + ✕ 2, ⚠ 2 + [root] + ✕ + ⚠ + ✕⚠ + `); + + act( + () => + (store.componentFilters = [utils.createDisplayNameFilter('Warning')]), + ); + expect(store).toMatchInlineSnapshot(` + ✕ 1, ⚠ 0 + [root] + ✕ + `); + + act( + () => + (store.componentFilters = [utils.createDisplayNameFilter('Error')]), + ); + expect(store).toMatchInlineSnapshot(` + ✕ 0, ⚠ 1 + [root] + ⚠ + `); + + 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] + ✕ + ⚠ + ✕⚠ + `); + }); }); }); diff --git a/packages/react-devtools-shared/src/__tests__/treeContext-test.js b/packages/react-devtools-shared/src/__tests__/treeContext-test.js index 0b672f91415f6..8c057fb720807 100644 --- a/packages/react-devtools-shared/src/__tests__/treeContext-test.js +++ b/packages/react-devtools-shared/src/__tests__/treeContext-test.js @@ -22,6 +22,7 @@ describe('TreeListContext', () => { let bridge: FrontendBridge; let store: Store; let utils; + let withErrorsOrWarningsIgnored; let BridgeContext; let StoreContext; @@ -34,6 +35,8 @@ describe('TreeListContext', () => { utils = require('./utils'); utils.beforeEachProfiling(); + withErrorsOrWarningsIgnored = utils.withErrorsOrWarningsIgnored; + bridge = global.bridge; store = global.store; store.collapseNodesByDefault = false; @@ -88,43 +91,111 @@ describe('TreeListContext', () => { ReactDOM.render(, document.createElement('div')), ); - expect(store).toMatchSnapshot('0: mount'); - let renderer; utils.act(() => (renderer = TestRenderer.create())); - expect(state).toMatchSnapshot('1: initial state'); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + + + `); + + // Test stepping through to the end utils.act(() => dispatch({type: 'SELECT_NEXT_ELEMENT_IN_TREE'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('2: select first element'); + expect(state).toMatchInlineSnapshot(` + [root] + → ▾ + ▾ + + + `); - while ( - state.selectedElementIndex !== null && - state.selectedElementIndex < store.numElements - 1 - ) { - const index = ((state.selectedElementIndex: any): number); - utils.act(() => dispatch({type: 'SELECT_NEXT_ELEMENT_IN_TREE'})); - utils.act(() => renderer.update()); - expect(state).toMatchSnapshot(`3: select element after (${index})`); - } + utils.act(() => dispatch({type: 'SELECT_NEXT_ELEMENT_IN_TREE'})); + utils.act(() => renderer.update()); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + → ▾ + + + `); - while ( - state.selectedElementIndex !== null && - state.selectedElementIndex > 0 - ) { - const index = ((state.selectedElementIndex: any): number); - utils.act(() => dispatch({type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE'})); - utils.act(() => renderer.update()); - expect(state).toMatchSnapshot(`4: select element before (${index})`); - } + utils.act(() => dispatch({type: 'SELECT_NEXT_ELEMENT_IN_TREE'})); + utils.act(() => renderer.update()); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + → + + `); + + utils.act(() => dispatch({type: 'SELECT_NEXT_ELEMENT_IN_TREE'})); + utils.act(() => renderer.update()); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + + → + `); + + // Test stepping back to the beginning + + utils.act(() => dispatch({type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE'})); + utils.act(() => renderer.update()); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + → + + `); + + utils.act(() => dispatch({type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE'})); + utils.act(() => renderer.update()); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + → ▾ + + + `); + + utils.act(() => dispatch({type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE'})); + utils.act(() => renderer.update()); + expect(state).toMatchInlineSnapshot(` + [root] + → ▾ + ▾ + + + `); + + // Test wrap around behavior utils.act(() => dispatch({type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('5: select previous wraps around to last'); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + + → + `); utils.act(() => dispatch({type: 'SELECT_NEXT_ELEMENT_IN_TREE'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('6: select next wraps around to first'); + expect(state).toMatchInlineSnapshot(` + [root] + → ▾ + ▾ + + + `); }); it('should select child elements', () => { @@ -146,30 +217,71 @@ describe('TreeListContext', () => { ReactDOM.render(, document.createElement('div')), ); - expect(store).toMatchSnapshot('0: mount'); - let renderer; utils.act(() => (renderer = TestRenderer.create())); - expect(state).toMatchSnapshot('1: initial state'); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + + + ▾ + + + `); utils.act(() => dispatch({type: 'SELECT_ELEMENT_AT_INDEX', payload: 0})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('2: select first element'); + expect(state).toMatchInlineSnapshot(` + [root] + → ▾ + ▾ + + + ▾ + + + `); utils.act(() => dispatch({type: 'SELECT_CHILD_ELEMENT_IN_TREE'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('3: select Parent'); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + → ▾ + + + ▾ + + + `); utils.act(() => dispatch({type: 'SELECT_CHILD_ELEMENT_IN_TREE'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('4: select Child'); - - const previousState = state; + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + → + + ▾ + + + `); // There are no more children to select, so this should be a no-op utils.act(() => dispatch({type: 'SELECT_CHILD_ELEMENT_IN_TREE'})); utils.act(() => renderer.update()); - expect(state).toEqual(previousState); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + → + + ▾ + + + `); }); it('should select parent elements and then collapse', () => { @@ -191,27 +303,78 @@ describe('TreeListContext', () => { ReactDOM.render(, document.createElement('div')), ); - expect(store).toMatchSnapshot('0: mount'); - let renderer; utils.act(() => (renderer = TestRenderer.create())); - expect(state).toMatchSnapshot('1: initial state'); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + + + ▾ + + + `); const lastChildID = store.getElementIDAtIndex(store.numElements - 1); + // Select the last child utils.act(() => dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: lastChildID}), ); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('2: select last child'); - + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + + + ▾ + + → + `); + + // Select its parent utils.act(() => dispatch({type: 'SELECT_PARENT_ELEMENT_IN_TREE'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('3: select Parent'); - + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + + + → ▾ + + + `); + + // Select grandparent utils.act(() => dispatch({type: 'SELECT_PARENT_ELEMENT_IN_TREE'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('4: select Grandparent'); + expect(state).toMatchInlineSnapshot(` + [root] + → ▾ + ▾ + + + ▾ + + + `); + + // No-op + utils.act(() => dispatch({type: 'SELECT_PARENT_ELEMENT_IN_TREE'})); + utils.act(() => renderer.update()); + expect(state).toMatchInlineSnapshot(` + [root] + → ▾ + ▾ + + + ▾ + + + `); const previousState = state; @@ -221,7 +384,7 @@ describe('TreeListContext', () => { expect(state).toEqual(previousState); }); - it('should clear selection if the selected element is unmounted', async done => { + it('should clear selection if the selected element is unmounted', async () => { const Grandparent = props => props.children || null; const Parent = props => props.children || null; const Child = () => null; @@ -239,16 +402,28 @@ describe('TreeListContext', () => { ), ); - expect(store).toMatchSnapshot('0: mount'); - let renderer; utils.act(() => (renderer = TestRenderer.create())); - expect(state).toMatchSnapshot('1: initial state'); - + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + + + `); + + // Select the second child utils.act(() => dispatch({type: 'SELECT_ELEMENT_AT_INDEX', payload: 3})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('2: select second child'); - + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + + → + `); + + // Remove the child (which should auto-select the parent) await utils.actAsync(() => ReactDOM.render( @@ -257,16 +432,15 @@ describe('TreeListContext', () => { container, ), ); - expect(state).toMatchSnapshot( - '3: remove children (parent should now be selected)', - ); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + → + `); + // Unmount the root (so that nothing is selected) await utils.actAsync(() => ReactDOM.unmountComponentAtNode(container)); - expect(state).toMatchSnapshot( - '4: unmount root (nothing should be selected)', - ); - - done(); + expect(state).toMatchInlineSnapshot(``); }); it('should navigate next/previous sibling and skip over children in between', () => { @@ -287,21 +461,6 @@ describe('TreeListContext', () => { ReactDOM.render(, document.createElement('div')), ); - /* - * 0 ▾ - * 1 ▾ - * 2 - * 3 ▾ - * 4 - * 5 - * 6 - * 7 ▾ - * 8 - * 9 - */ - - expect(store).toMatchSnapshot('0: mount'); - let renderer; utils.act(() => (renderer = TestRenderer.create())); @@ -311,31 +470,131 @@ describe('TreeListContext', () => { dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: firstParentID}), ); utils.act(() => renderer.update()); - expect(state.selectedElementIndex).toBe(1); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + → ▾ + + ▾ + + + + ▾ + + + `); utils.act(() => dispatch({type: 'SELECT_NEXT_SIBLING_IN_TREE'})); utils.act(() => renderer.update()); - expect(state.selectedElementIndex).toBe(3); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + + → ▾ + + + + ▾ + + + `); utils.act(() => dispatch({type: 'SELECT_NEXT_SIBLING_IN_TREE'})); utils.act(() => renderer.update()); - expect(state.selectedElementIndex).toBe(7); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + + ▾ + + + + → ▾ + + + `); utils.act(() => dispatch({type: 'SELECT_NEXT_SIBLING_IN_TREE'})); utils.act(() => renderer.update()); - expect(state.selectedElementIndex).toBe(1); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + → ▾ + + ▾ + + + + ▾ + + + `); + + utils.act(() => dispatch({type: 'SELECT_PREVIOUS_SIBLING_IN_TREE'})); + utils.act(() => renderer.update()); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + + ▾ + + + + → ▾ + + + `); utils.act(() => dispatch({type: 'SELECT_PREVIOUS_SIBLING_IN_TREE'})); utils.act(() => renderer.update()); - expect(state.selectedElementIndex).toBe(7); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + + → ▾ + + + + ▾ + + + `); utils.act(() => dispatch({type: 'SELECT_PREVIOUS_SIBLING_IN_TREE'})); utils.act(() => renderer.update()); - expect(state.selectedElementIndex).toBe(3); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + → ▾ + + ▾ + + + + ▾ + + + `); utils.act(() => dispatch({type: 'SELECT_PREVIOUS_SIBLING_IN_TREE'})); utils.act(() => renderer.update()); - expect(state.selectedElementIndex).toBe(1); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + + ▾ + + + + → ▾ + + + `); }); it('should navigate the owner hierarchy', () => { @@ -363,24 +622,6 @@ describe('TreeListContext', () => { ReactDOM.render(, document.createElement('div')), ); - /* - * 0 ▾ - * 1 ▾ - * 2 ▾ - * 3 - * 4 ▾ - * 5 ▾ - * 6 - * 7 - * 8 - * 9 ▾ - * 10 ▾ - * 11 - * 12 - */ - - expect(store).toMatchSnapshot('0: mount'); - let renderer; utils.act(() => (renderer = TestRenderer.create())); @@ -389,86 +630,281 @@ describe('TreeListContext', () => { dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: childID}), ); utils.act(() => renderer.update()); - expect(state.ownerSubtreeLeafElementID).toBeNull(); - expect(state.selectedElementIndex).toBe(7); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + ▾ + + ▾ + ▾ + + → + + ▾ + ▾ + + + `); // Basic navigation test utils.act(() => dispatch({type: 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE'}), ); utils.act(() => renderer.update()); - expect(state.ownerSubtreeLeafElementID).toBe(childID); - expect(state.selectedElementIndex).toBe(5); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + ▾ + + ▾ + → ▾ + + + + ▾ + ▾ + + + `); utils.act(() => dispatch({type: 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE'}), ); utils.act(() => renderer.update()); - expect(state.selectedElementIndex).toBe(0); - + expect(state).toMatchInlineSnapshot(` + [root] + → ▾ + ▾ + ▾ + + ▾ + ▾ + + + + ▾ + ▾ + + + `); + + // Noop (since we're at the root already) utils.act(() => dispatch({type: 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE'}), ); utils.act(() => renderer.update()); - expect(state.selectedElementIndex).toBe(0); // noop since we're at the top + expect(state).toMatchInlineSnapshot(` + [root] + → ▾ + ▾ + ▾ + + ▾ + ▾ + + + + ▾ + ▾ + + + `); utils.act(() => dispatch({type: 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE'}), ); utils.act(() => renderer.update()); - expect(state.selectedElementIndex).toBe(5); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + ▾ + + ▾ + → ▾ + + + + ▾ + ▾ + + + `); utils.act(() => dispatch({type: 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE'}), ); utils.act(() => renderer.update()); - expect(state.selectedElementIndex).toBe(7); - + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + ▾ + + ▾ + ▾ + + → + + ▾ + ▾ + + + `); + + // Noop (since we're at the leaf node) utils.act(() => dispatch({type: 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE'}), ); utils.act(() => renderer.update()); - expect(state.selectedElementIndex).toBe(7); // noop since we're at the leaf node + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + ▾ + + ▾ + ▾ + + → + + ▾ + ▾ + + + `); // Other navigational actions should clear out the temporary owner chain. utils.act(() => dispatch({type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE'})); utils.act(() => renderer.update()); - expect(state.selectedElementIndex).toBe(6); - expect(state.ownerSubtreeLeafElementID).toBeNull(); - + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + ▾ + + ▾ + ▾ + → + + + ▾ + ▾ + + + `); + + // Start a new tree on parent const parentID = ((store.getElementIDAtIndex(5): any): number); utils.act(() => dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: parentID}), ); utils.act(() => renderer.update()); - expect(state.ownerSubtreeLeafElementID).toBeNull(); - expect(state.selectedElementIndex).toBe(5); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + ▾ + + ▾ + → ▾ + + + + ▾ + ▾ + + + `); - // It should not be possible to navigate beyond the owner chain leaf. utils.act(() => dispatch({type: 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE'}), ); utils.act(() => renderer.update()); - expect(state.ownerSubtreeLeafElementID).toBe(parentID); - expect(state.selectedElementIndex).toBe(0); - + expect(state).toMatchInlineSnapshot(` + [root] + → ▾ + ▾ + ▾ + + ▾ + ▾ + + + + ▾ + ▾ + + + `); + + // Noop (since we're at the top) utils.act(() => dispatch({type: 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE'}), ); utils.act(() => renderer.update()); - expect(state.selectedElementIndex).toBe(0); // noop since we're at the top + expect(state).toMatchInlineSnapshot(` + [root] + → ▾ + ▾ + ▾ + + ▾ + ▾ + + + + ▾ + ▾ + + + `); utils.act(() => dispatch({type: 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE'}), ); utils.act(() => renderer.update()); - expect(state.selectedElementIndex).toBe(5); - + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + ▾ + + ▾ + → ▾ + + + + ▾ + ▾ + + + `); + + // Noop (since we're at the leaf of this owner tree) + // It should not be possible to navigate beyond the owner chain leaf. utils.act(() => dispatch({type: 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE'}), ); utils.act(() => renderer.update()); - expect(state.selectedElementIndex).toBe(5); // noop since we're at the leaf node + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + ▾ + + ▾ + → ▾ + + + + ▾ + ▾ + + + `); }); }); @@ -493,31 +929,59 @@ describe('TreeListContext', () => { ), ); - expect(store).toMatchSnapshot('0: mount'); - let renderer; utils.act(() => (renderer = TestRenderer.create())); - expect(state).toMatchSnapshot('1: initial state'); + expect(state).toMatchInlineSnapshot(` + [root] + + + + [withHOC] + `); // NOTE: multi-match utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('2: search for "ba"'); + expect(state).toMatchInlineSnapshot(` + [root] + + → + + [withHOC] + `); // NOTE: single match utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'f'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('3: search for "f"'); + expect(state).toMatchInlineSnapshot(` + [root] + → + + + [withHOC] + `); // NOTE: no match utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'y'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('4: search for "y"'); + expect(state).toMatchInlineSnapshot(` + [root] + → + + + [withHOC] + `); // NOTE: HOC match utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'w'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('5: search for "w"'); + expect(state).toMatchInlineSnapshot(` + [root] + + + + → [withHOC] + `); }); it('should select the next and previous items within the search results', () => { @@ -537,42 +1001,95 @@ describe('TreeListContext', () => { ), ); - expect(store).toMatchSnapshot('0: mount'); - let renderer; utils.act(() => (renderer = TestRenderer.create())); - expect(state).toMatchSnapshot('1: initial state'); - + expect(state).toMatchInlineSnapshot(` + [root] + + + + + `); + + // search for "ba" utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('2: search for "ba"'); - + expect(state).toMatchInlineSnapshot(` + [root] + + → + + + `); + + // go to second result utils.act(() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('3: go to second result'); - + expect(state).toMatchInlineSnapshot(` + [root] + + + → + + `); + + // go to third result utils.act(() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('4: go to third result'); - + expect(state).toMatchInlineSnapshot(` + [root] + + + + → + `); + + // go to second result utils.act(() => dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('5: go to second result'); - + expect(state).toMatchInlineSnapshot(` + [root] + + + → + + `); + + // go to first result utils.act(() => dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('6: go to first result'); - + expect(state).toMatchInlineSnapshot(` + [root] + + → + + + `); + + // wrap to last result utils.act(() => dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('7: wrap to last result'); - + expect(state).toMatchInlineSnapshot(` + [root] + + + + → + `); + + // wrap to first result utils.act(() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('8: wrap to first result'); + expect(state).toMatchInlineSnapshot(` + [root] + + → + + + `); }); - it('should add newly mounted elements to the search results set if they match the current text', async done => { + it('should add newly mounted elements to the search results set if they match the current text', async () => { const Foo = () => null; const Bar = () => null; const Baz = () => null; @@ -589,15 +1106,21 @@ describe('TreeListContext', () => { ), ); - expect(store).toMatchSnapshot('0: mount'); - let renderer; utils.act(() => (renderer = TestRenderer.create())); - expect(state).toMatchSnapshot('1: initial state'); + expect(state).toMatchInlineSnapshot(` + [root] + + + `); utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('2: search for "ba"'); + expect(state).toMatchInlineSnapshot(` + [root] + + → + `); await utils.actAsync(() => ReactDOM.render( @@ -610,12 +1133,24 @@ describe('TreeListContext', () => { ), ); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('3: mount Baz'); + expect(state).toMatchInlineSnapshot(` + [root] + + → + + `); - done(); + utils.act(() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'})); + utils.act(() => renderer.update()); + expect(state).toMatchInlineSnapshot(` + [root] + + + → + `); }); - it('should remove unmounted elements from the search results set', async done => { + it('should remove unmounted elements from the search results set', async () => { const Foo = () => null; const Bar = () => null; const Baz = () => null; @@ -633,19 +1168,32 @@ describe('TreeListContext', () => { ), ); - expect(store).toMatchSnapshot('0: mount'); - let renderer; utils.act(() => (renderer = TestRenderer.create())); - expect(state).toMatchSnapshot('1: initial state'); + expect(state).toMatchInlineSnapshot(` + [root] + + + + `); utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('2: search for "ba"'); + expect(state).toMatchInlineSnapshot(` + [root] + + → + + `); utils.act(() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('3: go to second result'); + expect(state).toMatchInlineSnapshot(` + [root] + + + → + `); await utils.actAsync(() => ReactDOM.render( @@ -657,9 +1205,28 @@ describe('TreeListContext', () => { ), ); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('4: unmount Baz'); + expect(state).toMatchInlineSnapshot(` + [root] + + + `); + + utils.act(() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'})); + utils.act(() => renderer.update()); + expect(state).toMatchInlineSnapshot(` + [root] + + → + `); - done(); + // Noop since the list is now one item long + utils.act(() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'})); + utils.act(() => renderer.update()); + expect(state).toMatchInlineSnapshot(` + [root] + + → + `); }); }); @@ -678,23 +1245,38 @@ describe('TreeListContext', () => { ReactDOM.render(, document.createElement('div')), ); - expect(store).toMatchSnapshot('0: mount'); - let renderer; utils.act(() => (renderer = TestRenderer.create())); - expect(state).toMatchSnapshot('1: initial state'); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + + + `); const parentID = ((store.getElementIDAtIndex(1): any): number); utils.act(() => dispatch({type: 'SELECT_OWNER', payload: parentID})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('2: parent owners tree'); + expect(state).toMatchInlineSnapshot(` + [owners] + → ▾ + + + `); utils.act(() => dispatch({type: 'RESET_OWNER_STACK'})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('3: final state'); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + → ▾ + + + `); }); - it('should remove an element from the owners list if it is unmounted', async done => { + it('should remove an element from the owners list if it is unmounted', async () => { const Grandparent = ({count}) => ; const Parent = ({count}) => new Array(count).fill(true).map((_, index) => ); @@ -703,31 +1285,45 @@ describe('TreeListContext', () => { const container = document.createElement('div'); utils.act(() => ReactDOM.render(, container)); - expect(store).toMatchSnapshot('0: mount'); - let renderer; utils.act(() => (renderer = TestRenderer.create())); - expect(state).toMatchSnapshot('1: initial state'); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + + + `); const parentID = ((store.getElementIDAtIndex(1): any): number); utils.act(() => dispatch({type: 'SELECT_OWNER', payload: parentID})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('2: parent owners tree'); + expect(state).toMatchInlineSnapshot(` + [owners] + → ▾ + + + `); await utils.actAsync(() => ReactDOM.render(, container), ); - expect(state).toMatchSnapshot('3: remove second child'); + expect(state).toMatchInlineSnapshot(` + [owners] + → ▾ + + `); await utils.actAsync(() => ReactDOM.render(, container), ); - expect(state).toMatchSnapshot('4: remove first child'); - - done(); + expect(state).toMatchInlineSnapshot(` + [owners] + → + `); }); - it('should exit the owners list if the current owner is unmounted', async done => { + it('should exit the owners list if the current owner is unmounted', async () => { const Parent = props => props.children || null; const Child = () => null; @@ -741,29 +1337,38 @@ describe('TreeListContext', () => { ), ); - expect(store).toMatchSnapshot('0: mount'); - let renderer; utils.act(() => (renderer = TestRenderer.create())); - expect(state).toMatchSnapshot('1: initial state'); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + + `); const childID = ((store.getElementIDAtIndex(1): any): number); utils.act(() => dispatch({type: 'SELECT_OWNER', payload: childID})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('2: child owners tree'); + expect(state).toMatchInlineSnapshot(` + [owners] + → + `); await utils.actAsync(() => ReactDOM.render(, container)); - expect(state).toMatchSnapshot('3: remove child'); + expect(state).toMatchInlineSnapshot(` + [root] + → + `); const parentID = ((store.getElementIDAtIndex(0): any): number); utils.act(() => dispatch({type: 'SELECT_OWNER', payload: parentID})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('4: parent owners tree'); + expect(state).toMatchInlineSnapshot(` + [owners] + → + `); await utils.actAsync(() => ReactDOM.unmountComponentAtNode(container)); - expect(state).toMatchSnapshot('5: unmount root'); - - done(); + expect(state).toMatchInlineSnapshot(``); }); // This tests ensures support for toggling Suspense boundaries outside of the active owners list. @@ -783,11 +1388,16 @@ describe('TreeListContext', () => { const container = document.createElement('div'); utils.act(() => ReactDOM.render(, container)); - expect(store).toMatchSnapshot('0: mount'); - let renderer; utils.act(() => (renderer = TestRenderer.create())); - expect(state).toMatchSnapshot('1: initial state'); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + ▾ + ▾ + + `); const outerSuspenseID = ((store.getElementIDAtIndex(1): any): number); const childID = ((store.getElementIDAtIndex(2): any): number); @@ -795,21 +1405,1212 @@ describe('TreeListContext', () => { utils.act(() => dispatch({type: 'SELECT_OWNER', payload: childID})); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('2: child owners tree'); + expect(state).toMatchInlineSnapshot(` + [owners] + → ▾ + ▾ + + `); // Toggling a Suspense boundary inside of the flat list should update selected index utils.act(() => dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: innerSuspenseID}), ); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('3: child owners tree'); + expect(state).toMatchInlineSnapshot(` + [owners] + ▾ + → ▾ + + `); // Toggling a Suspense boundary outside of the flat list should exit owners list and update index utils.act(() => dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: outerSuspenseID}), ); utils.act(() => renderer.update()); - expect(state).toMatchSnapshot('4: main tree'); + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + → ▾ + ▾ + ▾ + + `); + }); + }); + + describe('inline errors/warnings state', () => { + function clearAllErrors() { + utils.act(() => store.clearErrorsAndWarnings()); + // flush events to the renderer + jest.runAllTimers(); + } + + function clearErrorsForElement(id) { + utils.act(() => store.clearErrorsForElement(id)); + // flush events to the renderer + jest.runAllTimers(); + } + + function clearWarningsForElement(id) { + utils.act(() => store.clearWarningsForElement(id)); + // flush events to the renderer + jest.runAllTimers(); + } + + function selectNextErrorOrWarning() { + utils.act(() => + dispatch({type: 'SELECT_NEXT_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE'}), + ); + } + + function selectPreviousErrorOrWarning() { + utils.act(() => + dispatch({ + type: 'SELECT_PREVIOUS_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE', + }), + ); + } + + function Child({logError = false, logWarning = false}) { + if (logError === true) { + console.error('test-only: error'); + } + if (logWarning === true) { + console.warn('test-only: warning'); + } + return null; + } + + it('should handle when there are no errors/warnings', () => { + utils.act(() => + ReactDOM.render( + + + + + , + document.createElement('div'), + ), + ); + + utils.act(() => TestRenderer.create()); + + expect(state).toMatchInlineSnapshot(` + [root] + + + + `); + + // Next/previous errors should be a no-op + selectPreviousErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + [root] + + + + `); + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + [root] + + + + `); + + utils.act(() => dispatch({type: 'SELECT_ELEMENT_AT_INDEX', payload: 0})); + expect(state).toMatchInlineSnapshot(` + [root] + → + + + `); + + // Next/previous errors should still be a no-op + selectPreviousErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + [root] + → + + + `); + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + [root] + → + + + `); + }); + + it('should cycle through the next errors/warnings and wrap around', () => { + withErrorsOrWarningsIgnored(['test-only:'], () => + utils.act(() => + ReactDOM.render( + + + + + + + , + document.createElement('div'), + ), + ), + ); + + utils.act(() => TestRenderer.create()); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + ⚠ + + ✕ + + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + → ⚠ + + ✕ + + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + ⚠ + + → ✕ + + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + → ⚠ + + ✕ + + `); + }); + + it('should cycle through the previous errors/warnings and wrap around', () => { + withErrorsOrWarningsIgnored(['test-only:'], () => + utils.act(() => + ReactDOM.render( + + + + + + + , + document.createElement('div'), + ), + ), + ); + + utils.act(() => TestRenderer.create()); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + ⚠ + + ✕ + + `); + + selectPreviousErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + ⚠ + + → ✕ + + `); + + selectPreviousErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + → ⚠ + + ✕ + + `); + + selectPreviousErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + ⚠ + + → ✕ + + `); + }); + + it('should cycle through the next errors/warnings and wrap around with multiple roots', () => { + withErrorsOrWarningsIgnored(['test-only:'], () => { + utils.act(() => { + ReactDOM.render( + + + , + , + document.createElement('div'), + ); + ReactDOM.render( + + + + + , + document.createElement('div'), + ); + }); + }); + + utils.act(() => TestRenderer.create()); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + ⚠ + [root] + + ✕ + + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + → ⚠ + [root] + + ✕ + + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + ⚠ + [root] + + → ✕ + + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + → ⚠ + [root] + + ✕ + + `); + }); + + it('should cycle through the previous errors/warnings and wrap around with multiple roots', () => { + withErrorsOrWarningsIgnored(['test-only:'], () => { + utils.act(() => { + ReactDOM.render( + + + , + , + document.createElement('div'), + ); + ReactDOM.render( + + + + + , + document.createElement('div'), + ); + }); + }); + + utils.act(() => TestRenderer.create()); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + ⚠ + [root] + + ✕ + + `); + + selectPreviousErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + ⚠ + [root] + + → ✕ + + `); + + selectPreviousErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + → ⚠ + [root] + + ✕ + + `); + + selectPreviousErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + ⚠ + [root] + + → ✕ + + `); + }); + + it('should select the next or previous element relative to the current selection', () => { + withErrorsOrWarningsIgnored(['test-only:'], () => + utils.act(() => + ReactDOM.render( + + + + + + + , + document.createElement('div'), + ), + ), + ); + + utils.act(() => TestRenderer.create()); + utils.act(() => dispatch({type: 'SELECT_ELEMENT_AT_INDEX', payload: 2})); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + ⚠ + → + ✕ + + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + ⚠ + + → ✕ + + `); + + utils.act(() => dispatch({type: 'SELECT_ELEMENT_AT_INDEX', payload: 2})); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + ⚠ + → + ✕ + + `); + + selectPreviousErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + + → ⚠ + + ✕ + + `); + }); + + it('should update correctly when errors/warnings are cleared for a fiber in the list', () => { + withErrorsOrWarningsIgnored(['test-only:'], () => + utils.act(() => + ReactDOM.render( + + + + + + , + document.createElement('div'), + ), + ), + ); + + utils.act(() => TestRenderer.create()); + expect(state).toMatchInlineSnapshot(` + ✕ 2, ⚠ 2 + [root] + ⚠ + ✕ + ✕ + ⚠ + `); + + // Select the first item in the list + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 2, ⚠ 2 + [root] + → ⚠ + ✕ + ✕ + ⚠ + `); + + // Clear warnings (but the next Fiber has only errors) + clearWarningsForElement(store.getElementIDAtIndex(1)); + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 2, ⚠ 2 + [root] + ⚠ + → ✕ + ✕ + ⚠ + `); + + clearErrorsForElement(store.getElementIDAtIndex(2)); + + // Should step to the (now) next one in the list. + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 2 + [root] + ⚠ + ✕ + + → ⚠ + `); + + // Should skip over the (now) cleared Fiber + selectPreviousErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 2 + [root] + ⚠ + → ✕ + + ⚠ + `); + }); + + it('should update correctly when errors/warnings are cleared for the currently selected fiber', () => { + withErrorsOrWarningsIgnored(['test-only:'], () => + utils.act(() => + ReactDOM.render( + + + + , + document.createElement('div'), + ), + ), + ); + + utils.act(() => TestRenderer.create()); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + ⚠ + ✕ + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + → ⚠ + ✕ + `); + + clearWarningsForElement(store.getElementIDAtIndex(0)); + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 0 + [root] + + → ✕ + `); + }); + + it('should update correctly when new errors/warnings are added', () => { + const container = document.createElement('div'); + + withErrorsOrWarningsIgnored(['test-only:'], () => + utils.act(() => + ReactDOM.render( + + + + + + , + container, + ), + ), + ); + + utils.act(() => TestRenderer.create()); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + ⚠ + + + ✕ + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + → ⚠ + + + ✕ + `); + + withErrorsOrWarningsIgnored(['test-only:'], () => + utils.act(() => + ReactDOM.render( + + + + + + , + container, + ), + ), + ); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 2 + [root] + ⚠ + → ⚠ + + ✕ + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 2 + [root] + ⚠ + ⚠ + + → ✕ + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 2 + [root] + → ⚠ + ⚠ + + ✕ + `); + }); + + it('should update correctly when all errors/warnings are cleared', () => { + withErrorsOrWarningsIgnored(['test-only:'], () => + utils.act(() => + ReactDOM.render( + + + + , + document.createElement('div'), + ), + ), + ); + + utils.act(() => TestRenderer.create()); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + ⚠ + ✕ + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 + [root] + → ⚠ + ✕ + `); + + clearAllErrors(); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + [root] + → + + `); + + selectPreviousErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + [root] + → + + `); + }); + + it('should update select and auto-expand parts components within hidden parts of the tree', () => { + const Wrapper = ({children}) => children; + + store.collapseNodesByDefault = true; + + withErrorsOrWarningsIgnored(['test-only:'], () => + utils.act(() => + ReactDOM.render( + + + + + + + + + + , + document.createElement('div'), + ), + ), + ); + + utils.act(() => TestRenderer.create()); + expect(state).toMatchInlineSnapshot(` + ✕ 0, ⚠ 2 + [root] + ▸ + ▸ + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 0, ⚠ 2 + [root] + ▾ + → ⚠ + ▸ + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 0, ⚠ 2 + [root] + ▾ + ⚠ + ▾ + ▾ + → ⚠ + `); + }); + + it('should properly handle when components filters are updated', () => { + const Wrapper = ({children}) => children; + + withErrorsOrWarningsIgnored(['test-only:'], () => + utils.act(() => + ReactDOM.render( + + + + + + + + + + , + document.createElement('div'), + ), + ), + ); + + utils.act(() => TestRenderer.create()); + expect(state).toMatchInlineSnapshot(` + ✕ 0, ⚠ 2 + [root] + ▾ + ⚠ + ▾ + ▾ + ⚠ + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 0, ⚠ 2 + [root] + ▾ + → ⚠ + ▾ + ▾ + ⚠ + `); + + utils.act(() => { + store.componentFilters = [utils.createDisplayNameFilter('Wrapper')]; + }); + expect(state).toMatchInlineSnapshot(` + ✕ 0, ⚠ 2 + [root] + → ⚠ + ⚠ + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 0, ⚠ 2 + [root] + ⚠ + → ⚠ + `); + + utils.act(() => { + store.componentFilters = []; + }); + expect(state).toMatchInlineSnapshot(` + ✕ 0, ⚠ 2 + [root] + ▾ + ⚠ + ▾ + ▾ + → ⚠ + `); + + selectPreviousErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 0, ⚠ 2 + [root] + ▾ + → ⚠ + ▾ + ▾ + ⚠ + `); + }); + + it('should preserve errors for fibers even if they are filtered out of the tree initially', () => { + const Wrapper = ({children}) => children; + + withErrorsOrWarningsIgnored(['test-only:'], () => + utils.act(() => + ReactDOM.render( + + + + + + + + + + , + document.createElement('div'), + ), + ), + ); + + store.componentFilters = [utils.createDisplayNameFilter('Child')]; + + utils.act(() => TestRenderer.create()); + expect(state).toMatchInlineSnapshot(` + [root] + + ▾ + + `); + + utils.act(() => { + store.componentFilters = []; + }); + expect(state).toMatchInlineSnapshot(` + ✕ 0, ⚠ 2 + [root] + ▾ + ⚠ + ▾ + ▾ + ⚠ + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 0, ⚠ 2 + [root] + ▾ + → ⚠ + ▾ + ▾ + ⚠ + `); + }); + + describe('suspense', () => { + // This verifies that we don't flush before the tree has been committed. + it('should properly handle errors/warnings from components inside of delayed Suspense', async () => { + const NeverResolves = React.lazy(() => new Promise(() => {})); + + withErrorsOrWarningsIgnored(['test-only:'], () => + utils.act(() => + ReactDOM.render( + + + + , + document.createElement('div'), + ), + ), + ); + utils.act(() => TestRenderer.create()); + + jest.runAllTimers(); + + expect(state).toMatchInlineSnapshot(` + [root] + + `); + + selectNextErrorOrWarning(); + + expect(state).toMatchInlineSnapshot(` + [root] + + `); + }); + + it('should properly handle errors/warnings from components that dont mount because of Suspense', async () => { + async function fakeImport(result) { + return {default: result}; + } + const LazyComponent = React.lazy(() => fakeImport(Child)); + + const container = document.createElement('div'); + + withErrorsOrWarningsIgnored(['test-only:'], () => + utils.act(() => + ReactDOM.render( + + + + , + container, + ), + ), + ); + utils.act(() => TestRenderer.create()); + + expect(state).toMatchInlineSnapshot(` + [root] + + `); + + await Promise.resolve(); + withErrorsOrWarningsIgnored(['test-only:'], () => + utils.act(() => + ReactDOM.render( + + + + , + container, + ), + ), + ); + + expect(state).toMatchInlineSnapshot(` + ✕ 0, ⚠ 1 + [root] + ▾ + ⚠ + + `); + }); + + it('should properly show errors/warnings from components in the Suspense fallback tree', async () => { + async function fakeImport(result) { + return {default: result}; + } + const LazyComponent = React.lazy(() => fakeImport(Child)); + + const Fallback = () => ; + + const container = document.createElement('div'); + + withErrorsOrWarningsIgnored(['test-only:'], () => + utils.act(() => + ReactDOM.render( + }> + + , + container, + ), + ), + ); + utils.act(() => TestRenderer.create()); + + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 0 + [root] + ▾ + ▾ + ✕ + `); + + await Promise.resolve(); + withErrorsOrWarningsIgnored(['test-only:'], () => + utils.act(() => + ReactDOM.render( + }> + + , + container, + ), + ), + ); + + expect(state).toMatchInlineSnapshot(` + [root] + ▾ + + `); + }); + }); + + describe('error boundaries', () => { + it('should properly handle errors/warnings from components that dont mount because of an error', () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return null; + } + return this.props.children; + } + } + + class BadRender extends React.Component { + render() { + console.error('test-only: I am about to throw!'); + throw new Error('test-only: Oops!'); + } + } + + const container = document.createElement('div'); + withErrorsOrWarningsIgnored( + ['test-only:', 'React will try to recreate this component tree'], + () => { + utils.act(() => + ReactDOM.render( + + + , + container, + ), + ); + }, + ); + + utils.act(() => TestRenderer.create()); + + expect(store).toMatchInlineSnapshot(` + ✕ 1, ⚠ 0 + [root] + ✕ + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 0 + [root] + → ✕ + `); + + utils.act(() => ReactDOM.unmountComponentAtNode(container)); + expect(state).toMatchInlineSnapshot(``); + + // Should be a noop + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(``); + }); + + it('should properly handle errors/warnings from components that dont mount because of an error', () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return null; + } + return this.props.children; + } + } + + class LogsWarning extends React.Component { + render() { + console.warn('test-only: I am about to throw!'); + return ; + } + } + class ThrowsError extends React.Component { + render() { + throw new Error('test-only: Oops!'); + } + } + + const container = document.createElement('div'); + withErrorsOrWarningsIgnored( + ['test-only:', 'React will try to recreate this component tree'], + () => { + utils.act(() => + ReactDOM.render( + + + , + container, + ), + ); + }, + ); + + utils.act(() => TestRenderer.create()); + + expect(store).toMatchInlineSnapshot(` + ✕ 1, ⚠ 0 + [root] + ✕ + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 1, ⚠ 0 + [root] + → ✕ + `); + + utils.act(() => ReactDOM.unmountComponentAtNode(container)); + expect(state).toMatchInlineSnapshot(``); + + // Should be a noop + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(``); + }); + + it('should properly handle errors/warnings from inside of an error boundary', () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return ; + } + return this.props.children; + } + } + + class BadRender extends React.Component { + render() { + console.error('test-only: I am about to throw!'); + throw new Error('test-only: Oops!'); + } + } + + const container = document.createElement('div'); + withErrorsOrWarningsIgnored( + ['test-only:', 'React will try to recreate this component tree'], + () => { + utils.act(() => + ReactDOM.render( + + + , + container, + ), + ); + }, + ); + + utils.act(() => TestRenderer.create()); + + expect(store).toMatchInlineSnapshot(` + ✕ 2, ⚠ 0 + [root] + ▾ ✕ + ✕ + `); + + selectNextErrorOrWarning(); + expect(state).toMatchInlineSnapshot(` + ✕ 2, ⚠ 0 + [root] + → ▾ ✕ + ✕ + `); + }); }); }); }); diff --git a/packages/react-devtools-shared/src/__tests__/treeContextStateSerializer.js b/packages/react-devtools-shared/src/__tests__/treeContextStateSerializer.js new file mode 100644 index 0000000000000..866d4ed4c3662 --- /dev/null +++ b/packages/react-devtools-shared/src/__tests__/treeContextStateSerializer.js @@ -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); +} diff --git a/packages/react-devtools-shared/src/__tests__/utils.js b/packages/react-devtools-shared/src/__tests__/utils.js index 2f4325f8ca835..2fb0664a24855 100644 --- a/packages/react-devtools-shared/src/__tests__/utils.js +++ b/packages/react-devtools-shared/src/__tests__/utils.js @@ -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>( + 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 = []; + } + } +} diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index 1e0ffe336f685..326b0fb2e340f 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -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(); } diff --git a/packages/react-devtools-shared/src/backend/console.js b/packages/react-devtools-shared/src/backend/console.js index 9a29d5cb0582e..cc9eff11f6841 100644 --- a/packages/react-devtools-shared/src/backend/console.js +++ b/packages/react-devtools-shared/src/backend/console.js @@ -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, +) => 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; - - 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)); - - 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 lastArg = args.length > 0 ? args[args.length - 1] : null; + const alreadyHasComponentStack = + lastArg !== null && isStringComponentStack(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; + } + + 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; diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index 86d987c8f2e26..90f0f3ad12b46 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -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, diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index 983a17f51dc9f..11beef37f76d4 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -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 = new Set(); + + // Mapping of fiber IDs to error/warning messages and counts. + const fiberToErrorsMap: Map> = new Map(); + const fiberToWarningsMap: Map> = 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, + ): 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, diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index d87c2fdf97b2e..93ac3c7827e8b 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -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 | 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) => void, deletePath: ( type: Type, diff --git a/packages/react-devtools-shared/src/backend/utils.js b/packages/react-devtools-shared/src/backend/utils.js index d0ff4192d6edb..06329eff72150 100644 --- a/packages/react-devtools-shared/src/backend/utils.js +++ b/packages/react-devtools-shared/src/backend/utils.js @@ -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 +): 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; +} diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index 2bde2aed8b071..1da4d8eb3e549 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -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], diff --git a/packages/react-devtools-shared/src/constants.js b/packages/react-devtools-shared/src/constants.js index c5223f7cb43d9..f8868111e095d 100644 --- a/packages/react-devtools-shared/src/constants.js +++ b/packages/react-devtools-shared/src/constants.js @@ -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'; diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index cd829bb3bf3ab..6b1b83e524838 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -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; + // 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,7 +979,41 @@ 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: { @@ -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; diff --git a/packages/react-devtools-shared/src/devtools/utils.js b/packages/react-devtools-shared/src/devtools/utils.js index 46b15d18a1159..4e71d70bd68f5 100644 --- a/packages/react-devtools-shared/src/devtools/utils.js +++ b/packages/react-devtools-shared/src/devtools/utils.js @@ -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,40 +50,96 @@ 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); + function printSelectedMarker(index: number): string { + if (state === null) { + return ''; + } + return state.selectedElementIndex === index ? `→` : ' '; + } + + 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}`); - snapshotLines.push('[root]' + (includeWeight ? ` (${weight})` : '')); + for (let i = rootWeight; i < rootWeight + weight; i++) { + const element = store.getElementAtIndex(i); - 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}`); + } - 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}`, + ); } - snapshotLines.push(printElement(element, includeWeight)); - } + rootWeight += weight; + }); - 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})`, + ); + } - // 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(); } - // 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'); } diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Element.css b/packages/react-devtools-shared/src/devtools/views/Components/Element.css index cd92bd3937d37..a21b303a3504a 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Element.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/Element.css @@ -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); +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Element.js b/packages/react-devtools-shared/src/devtools/views/Components/Element.js index a209a55ace781..73789a9bd62ff 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Element.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Element.js @@ -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(` Could not find element at index ${index}`); + console.warn(` 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) { /> ) : null} + {showInlineWarningsAndErrors && errorCount > 0 && ( + + )} + {showInlineWarningsAndErrors && warningCount > 0 && ( + + )}
); @@ -160,7 +202,7 @@ const swallowDoubleClick = event => { }; type ExpandCollapseToggleProps = {| - element: Element, + element: ElementType, store: Store, |}; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js index f80f099f52479..01b05a9fa33a1 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js @@ -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(() => { + 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, ], ); diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementErrorsAndWarningsTree.css b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementErrorsAndWarningsTree.css new file mode 100644 index 0000000000000..6ef5583b8957c --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementErrorsAndWarningsTree.css @@ -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); +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementErrorsAndWarningsTree.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementErrorsAndWarningsTree.js new file mode 100644 index 0000000000000..1f161c98836b6 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementErrorsAndWarningsTree.js @@ -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 ( + + {errors.length > 0 && ( + + )} + {warnings.length > 0 && ( + + )} + + ); +} + +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 ( +
+
+
{label}
+ +
+ {entries.map(([message, count], index) => ( + + ))} +
+ ); +} + +type ErrorOrWarningViewProps = {| + badgeClassName: string, + className: string, + count: number, + message: string, +|}; + +function ErrorOrWarningView({ + className, + badgeClassName, + count, + message, +}: ErrorOrWarningViewProps) { + return ( +
+ {count > 1 &&
{count}
} +
+ {message} +
+
+ ); +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css index 2da0a11286bf6..0e76706bcf138 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css @@ -12,6 +12,8 @@ } .Header { + display: flex; + align-items: center; flex: 1 1; font-family: var(--font-family-sans); } diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js index fd2c514e57823..a1c77b9b37b18 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js @@ -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} /> + + {showRenderedBy && ( diff --git a/packages/react-devtools-shared/src/devtools/views/Components/SearchInput.js b/packages/react-devtools-shared/src/devtools/views/Components/SearchInput.js index 0fe2ccaa78ea3..736d39fc75244 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/SearchInput.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/SearchInput.js @@ -85,42 +85,44 @@ export default function SearchInput(props: Props) { value={searchText} /> {!!searchText && ( - - {Math.min(searchIndex + 1, searchResults.length)} |{' '} - {searchResults.length} - + + + {Math.min(searchIndex + 1, searchResults.length)} |{' '} + {searchResults.length} + +
+ + + + )} -
- - -
); } diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Tree.css b/packages/react-devtools-shared/src/devtools/views/Components/Tree.css index 64a5983ee06d9..2036695a50e34 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Tree.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/Tree.css @@ -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); +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Tree.js b/packages/react-devtools-shared/src/devtools/views/Components/Tree.js index fe5221a66f166..467af81b77f3d 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Tree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Tree.js @@ -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(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 (
@@ -315,6 +341,40 @@ export default function Tree(props: Props) { {ownerID !== null ? : }
+ {showInlineWarningsAndErrors && + ownerID === null && + (errors > 0 || warnings > 0) && ( + + {errors > 0 && ( +
+ + {errors} +
+ )} + {warnings > 0 && ( +
+ + {warnings} +
+ )} + + + +
+ + )}
- {ElementView} + {Element} )} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js index f748a9f3548af..a824bd493ff84 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js @@ -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': diff --git a/packages/react-devtools-shared/src/devtools/views/Components/types.js b/packages/react-devtools-shared/src/devtools/views/Components/types.js index cccaba1c9b151..efdb894636885 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/types.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/types.js @@ -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 | null, diff --git a/packages/react-devtools-shared/src/devtools/views/Icon.js b/packages/react-devtools-shared/src/devtools/views/Icon.js index db54b7091e425..ce02611127f31 100644 --- a/packages/react-devtools-shared/src/devtools/views/Icon.js +++ b/packages/react-devtools-shared/src/devtools/views/Icon.js @@ -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`; diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js index 55779509bfd17..85b5c875e4739 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js @@ -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}`); } diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/DebuggingSettings.js b/packages/react-devtools-shared/src/devtools/views/Settings/DebuggingSettings.js index bc26ead4fc150..8d5da190be457 100644 --- a/packages/react-devtools-shared/src/devtools/views/Settings/DebuggingSettings.js +++ b/packages/react-devtools-shared/src/devtools/views/Settings/DebuggingSettings.js @@ -19,6 +19,8 @@ export default function DebuggingSettings(_: {||}) { breakOnConsoleErrors, setAppendComponentStack, setBreakOnConsoleErrors, + setShowInlineWarningsAndErrors, + showInlineWarningsAndErrors, } = useContext(SettingsContext); return ( @@ -36,6 +38,19 @@ export default function DebuggingSettings(_: {||}) {
+
+ +
+
+ +
+ These settings require DevTools to override native console APIs. +
); } diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js index 2a89a9fe679b7..f63a901410fb6 100644 --- a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js @@ -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( + 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( diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsModal.css b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsModal.css index af400ba9161d0..7c1e6b55b4494 100644 --- a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsModal.css +++ b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsModal.css @@ -42,4 +42,4 @@ padding: 0.5rem; flex: 0 1 auto; overflow: auto; -} +} \ No newline at end of file diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsShared.css b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsShared.css index bcdc427668df3..8bf8b3de1fddf 100644 --- a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsShared.css +++ b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsShared.css @@ -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; diff --git a/packages/react-devtools-shared/src/devtools/views/root.css b/packages/react-devtools-shared/src/devtools/views/root.css index b49213ccdffee..1fc6d461a4da1 100644 --- a/packages/react-devtools-shared/src/devtools/views/root.css +++ b/packages/react-devtools-shared/src/devtools/views/root.css @@ -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; diff --git a/packages/react-devtools-shared/src/hook.js b/packages/react-devtools-shared/src/hook.js index 62234fdc46425..9bec6f40a1537 100644 --- a/packages/react-devtools-shared/src/hook.js +++ b/packages/react-devtools-shared/src/hook.js @@ -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) {} diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index 2c796c2228b8d..d84d5e67e98b6 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -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) { } 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) { // 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, diff --git a/packages/react-devtools-shell/src/app/InlineWarnings/index.js b/packages/react-devtools-shell/src/app/InlineWarnings/index.js new file mode 100644 index 0000000000000..9aa681b29381d --- /dev/null +++ b/packages/react-devtools-shell/src/app/InlineWarnings/index.js @@ -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
{children}
; +} + +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
{children}
; +} + +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
{children}
; +} + +function ComponentWithMissingKey({children}) { + return [
]; +} + +export default function ErrorsAndWarnings() { + const [count, setCount] = useState(0); + const handleClick = () => setCount(count + 1); + return ( + +

Inline warnings

+ + + + + + {count === 0 ? : null} + {count === 0 ? : null} + + + + {count === 0 ? : null} + + + + {count === 0 ? : null} + + + + + + +
+ ); +} diff --git a/packages/react-devtools-shell/src/app/index.js b/packages/react-devtools-shell/src/app/index.js index 8483be6b050a9..b3c7b9be228c1 100644 --- a/packages/react-devtools-shell/src/app/index.js +++ b/packages/react-devtools-shell/src/app/index.js @@ -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); diff --git a/packages/react-devtools/OVERVIEW.md b/packages/react-devtools/OVERVIEW.md index a59a9fcc3bfdc..ee73da31c64ab 100644 --- a/packages/react-devtools/OVERVIEW.md +++ b/packages/react-devtools/OVERVIEW.md @@ -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. \ No newline at end of file +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. diff --git a/scripts/jest/config.build-devtools.js b/scripts/jest/config.build-devtools.js index 7f1cd8855929b..39985e7416e18 100644 --- a/scripts/jest/config.build-devtools.js +++ b/scripts/jest/config.build-devtools.js @@ -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,