From 56efc65e5857abe22062c9506207abc35c9532d1 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 11 Nov 2021 14:22:22 -0500 Subject: [PATCH] DevTools should properly report re-renders due to (use)context changes (#22746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note that this only fixes things for newer versions of React (e.g. 18 alpha). Older versions will remain broken because there's not a good way to read the most recent context value for a location in the tree after render has completed. This is because React maintains a stack of context values during render, but by the time DevTools is called– render has finished and the stack is empty. --- .../profilerChangeDescriptions-test.js | 151 ++++++++++++++++++ .../src/backend/renderer.js | 12 +- 2 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 packages/react-devtools-shared/src/__tests__/profilerChangeDescriptions-test.js diff --git a/packages/react-devtools-shared/src/__tests__/profilerChangeDescriptions-test.js b/packages/react-devtools-shared/src/__tests__/profilerChangeDescriptions-test.js new file mode 100644 index 0000000000000..eee3a66b03a55 --- /dev/null +++ b/packages/react-devtools-shared/src/__tests__/profilerChangeDescriptions-test.js @@ -0,0 +1,151 @@ +/** + * 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 + */ + +describe('Profiler change descriptions', () => { + let React; + let legacyRender; + let store: Store; + let utils; + + beforeEach(() => { + utils = require('./utils'); + utils.beforeEachProfiling(); + + legacyRender = utils.legacyRender; + + store = global.store; + store.collapseNodesByDefault = false; + store.recordChangeDescriptions = true; + + React = require('react'); + }); + + it('should identify useContext as the cause for a re-render', () => { + const Context = React.createContext(0); + + function Child() { + const context = React.useContext(Context); + return context; + } + + function areEqual() { + return true; + } + + const MemoizedChild = React.memo(Child, areEqual); + const ForwardRefChild = React.forwardRef(function RefForwardingComponent( + props, + ref, + ) { + return ; + }); + + let forceUpdate = null; + + const App = function App() { + const [val, dispatch] = React.useReducer(x => x + 1, 0); + + forceUpdate = dispatch; + + return ( + + + + + + ); + }; + + const container = document.createElement('div'); + + utils.act(() => store.profilerStore.startProfiling()); + utils.act(() => legacyRender(, container)); + utils.act(() => forceUpdate()); + utils.act(() => store.profilerStore.stopProfiling()); + + const rootID = store.roots[0]; + const commitData = store.profilerStore.getCommitData(rootID, 1); + + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + ▾ + + ▾ [Memo] + + ▾ [ForwardRef] + + `); + + let element = store.getElementAtIndex(2); + expect(element.displayName).toBe('Child'); + expect(element.hocDisplayNames).toBeNull(); + expect(commitData.changeDescriptions.get(element.id)) + .toMatchInlineSnapshot(` + Object { + "context": true, + "didHooksChange": false, + "hooks": null, + "isFirstMount": false, + "props": Array [], + "state": null, + } + `); + + element = store.getElementAtIndex(3); + expect(element.displayName).toBe('Child'); + expect(element.hocDisplayNames).toEqual(['Memo']); + expect(commitData.changeDescriptions.get(element.id)).toBeUndefined(); + + element = store.getElementAtIndex(4); + expect(element.displayName).toBe('Child'); + expect(element.hocDisplayNames).toBeNull(); + expect(commitData.changeDescriptions.get(element.id)) + .toMatchInlineSnapshot(` + Object { + "context": true, + "didHooksChange": false, + "hooks": null, + "isFirstMount": false, + "props": Array [], + "state": null, + } + `); + + element = store.getElementAtIndex(5); + expect(element.displayName).toBe('RefForwardingComponent'); + expect(element.hocDisplayNames).toEqual(['ForwardRef']); + expect(commitData.changeDescriptions.get(element.id)) + .toMatchInlineSnapshot(` + Object { + "context": null, + "didHooksChange": false, + "hooks": null, + "isFirstMount": false, + "props": Array [], + "state": null, + } + `); + + element = store.getElementAtIndex(6); + expect(element.displayName).toBe('Child'); + expect(element.hocDisplayNames).toBeNull(); + expect(commitData.changeDescriptions.get(element.id)) + .toMatchInlineSnapshot(` + Object { + "context": true, + "didHooksChange": false, + "hooks": null, + "isFirstMount": false, + "props": Array [], + "state": null, + } + `); + }); +}); diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index 7ce78e564e390..8a89a5406c6ef 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -1253,8 +1253,10 @@ export function attach( function updateContextsForFiber(fiber: Fiber) { switch (getElementTypeForFiber(fiber)) { - case ElementTypeFunction: case ElementTypeClass: + case ElementTypeForwardRef: + case ElementTypeFunction: + case ElementTypeMemo: if (idToContextsMap !== null) { const id = getFiberIDThrows(fiber); const contexts = getContextsForFiber(fiber); @@ -1292,7 +1294,9 @@ export function attach( } } return [legacyContext, modernContext]; + case ElementTypeForwardRef: case ElementTypeFunction: + case ElementTypeMemo: const dependencies = fiber.dependencies; if (dependencies && dependencies.firstContext) { modernContext = dependencies.firstContext; @@ -1341,12 +1345,18 @@ export function attach( } } break; + case ElementTypeForwardRef: case ElementTypeFunction: + case ElementTypeMemo: if (nextModernContext !== NO_CONTEXT) { let prevContext = prevModernContext; let nextContext = nextModernContext; while (prevContext && nextContext) { + // Note this only works for versions of React that support this key (e.v. 18+) + // For older versions, there's no good way to read the current context value after render has completed. + // This is because React maintains a stack of context values during render, + // but by the time DevTools is called, render has finished and the stack is empty. if (!is(prevContext.memoizedValue, nextContext.memoizedValue)) { return true; }