Repository: facebook/react
Pull request: DevTools should properly report re-renders due to (use)context changes

Improves "why did this render?" logic for _memo_ and _forward ref_ components when rendering is caused by a change in context. (Relates to #22737.)

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 current 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.

I think this is the best we can do for now.

Changed files:
filename: packages/react-devtools-shared/src/__tests__/profilerChangeDescriptions-test.js
patch: @@ -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 <Child />;
+    });
+
+    let forceUpdate = null;
+
+    const App = function App() {
+      const [val, dispatch] = React.useReducer(x => x + 1, 0);
+
+      forceUpdate = dispatch;
+
+      return (
+        <Context.Provider value={val}>
+          <Child />
+          <MemoizedChild />
+          <ForwardRefChild />
+        </Context.Provider>
+      );
+    };
+
+    const container = document.createElement('div');
+
+    utils.act(() => store.profilerStore.startProfiling());
+    utils.act(() => legacyRender(<App />, 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]
+        ▾ <App>
+          ▾ <Context.Provider>
+              <Child>
+            ▾ <Child> [Memo]
+                <Child>
+            ▾ <RefForwardingComponent> [ForwardRef]
+                <Child>
+    `);
+
+    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,
+      }
+    `);
+  });
+});

filename: packages/react-devtools-shared/src/backend/renderer.js
patch: @@ -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;
               }