Repository: facebook/react
Pull request: Devtools/function context change

## Summary

Show if a function component rendered due to context changes. Fixes: https://github.com/facebook/react/issues/21736

I also added some additional usecases for verifying context change behavior in the `react-devtools-shell` test app - https://github.com/facebook/react/commit/ed07e58288bba3c80f4290eb7c8f592fe70ec701. Happy to remove this if it shouldn't be merged in.

On the test plan, only manual testing has been performed. I plan to add tests, but wanted to send out a PR first to get some eyes on the implementation.

## Test Plan

1. Given a function component consuming contexts `string` and `string2`,
when `string` changes,
the function component was rendered due to *Context changed*

https://user-images.githubusercontent.com/34072411/128655236-53a70429-c2bf-4f77-bdfd-141875a51564.mp4

2. Given a function component consuming context `string` and `string2`,
when `string2` changes,
the function component was rendered due to *Context changed*.

https://user-images.githubusercontent.com/34072411/128655259-c5e6b3a4-5a1f-4aad-8501-d61a539c7b8b.mp4

3. Given a function component consuming context and has internal state,
when internal state changes and context does not,
the function component was rendered due to *Hooks changed*.

https://user-images.githubusercontent.com/34072411/128655492-dd4a9dcc-0681-436c-bd76-42f312d7322c.mp4



Changed files:
filename: packages/react-devtools-shared/src/__tests__/__snapshots__/profilingCache-test.js.snap
patch: @@ -2485,7 +2485,7 @@ Object {
 exports[`ProfilingCache should properly detect changed hooks: CommitDetails commitIndex: 0 1`] = `
 Object {
   "changeDescriptions": Map {
-    3 => Object {
+    4 => Object {
       "context": null,
       "didHooksChange": false,
       "isFirstMount": true,
@@ -2499,11 +2499,13 @@ Object {
     1 => 0,
     2 => 0,
     3 => 0,
+    4 => 0,
   },
   "fiberSelfDurations": Map {
     1 => 0,
     2 => 0,
     3 => 0,
+    4 => 0,
   },
   "passiveEffectDuration": 0,
   "priorityLevel": "Immediate",
@@ -2523,8 +2525,8 @@ Object {
 exports[`ProfilingCache should properly detect changed hooks: CommitDetails commitIndex: 1 1`] = `
 Object {
   "changeDescriptions": Map {
-    3 => Object {
-      "context": null,
+    4 => Object {
+      "context": false,
       "didHooksChange": false,
       "isFirstMount": false,
       "props": Array [
@@ -2536,11 +2538,13 @@ Object {
   "duration": 0,
   "effectDuration": null,
   "fiberActualDurations": Map {
+    4 => 0,
     3 => 0,
     2 => 0,
     1 => 0,
   },
   "fiberSelfDurations": Map {
+    4 => 0,
     3 => 0,
     2 => 0,
     1 => 0,
@@ -2563,8 +2567,8 @@ Object {
 exports[`ProfilingCache should properly detect changed hooks: CommitDetails commitIndex: 2 1`] = `
 Object {
   "changeDescriptions": Map {
-    3 => Object {
-      "context": null,
+    4 => Object {
+      "context": false,
       "didHooksChange": true,
       "isFirstMount": false,
       "props": Array [],
@@ -2574,10 +2578,10 @@ Object {
   "duration": 0,
   "effectDuration": null,
   "fiberActualDurations": Map {
-    3 => 0,
+    4 => 0,
   },
   "fiberSelfDurations": Map {
-    3 => 0,
+    4 => 0,
   },
   "passiveEffectDuration": null,
   "priorityLevel": "Immediate",
@@ -2586,7 +2590,7 @@ Object {
     Object {
       "displayName": "Component",
       "hocDisplayNames": null,
-      "id": 3,
+      "id": 4,
       "key": null,
       "type": 5,
     },
@@ -2597,8 +2601,8 @@ Object {
 exports[`ProfilingCache should properly detect changed hooks: CommitDetails commitIndex: 3 1`] = `
 Object {
   "changeDescriptions": Map {
-    3 => Object {
-      "context": null,
+    4 => Object {
+      "context": false,
       "didHooksChange": true,
       "isFirstMount": false,
       "props": Array [],
@@ -2608,10 +2612,10 @@ Object {
   "duration": 0,
   "effectDuration": null,
   "fiberActualDurations": Map {
-    3 => 0,
+    4 => 0,
   },
   "fiberSelfDurations": Map {
-    3 => 0,
+    4 => 0,
   },
   "passiveEffectDuration": null,
   "priorityLevel": "Immediate",
@@ -2620,7 +2624,7 @@ Object {
     Object {
       "displayName": "Component",
       "hocDisplayNames": null,
-      "id": 3,
+      "id": 4,
       "key": null,
       "type": 5,
     },
@@ -2631,8 +2635,48 @@ Object {
 exports[`ProfilingCache should properly detect changed hooks: CommitDetails commitIndex: 4 1`] = `
 Object {
   "changeDescriptions": Map {
-    3 => Object {
-      "context": null,
+    4 => Object {
+      "context": true,
+      "didHooksChange": false,
+      "isFirstMount": false,
+      "props": Array [],
+      "state": null,
+    },
+  },
+  "duration": 0,
+  "effectDuration": null,
+  "fiberActualDurations": Map {
+    4 => 0,
+    3 => 0,
+    2 => 0,
+    1 => 0,
+  },
+  "fiberSelfDurations": Map {
+    4 => 0,
+    3 => 0,
+    2 => 0,
+    1 => 0,
+  },
+  "passiveEffectDuration": null,
+  "priorityLevel": "Immediate",
+  "timestamp": 0,
+  "updaters": Array [
+    Object {
+      "displayName": "Anonymous",
+      "hocDisplayNames": null,
+      "id": 1,
+      "key": null,
+      "type": 11,
+    },
+  ],
+}
+`;
+
+exports[`ProfilingCache should properly detect changed hooks: CommitDetails commitIndex: 5 1`] = `
+Object {
+  "changeDescriptions": Map {
+    4 => Object {
+      "context": true,
       "didHooksChange": false,
       "isFirstMount": false,
       "props": Array [],
@@ -2642,11 +2686,13 @@ Object {
   "duration": 0,
   "effectDuration": null,
   "fiberActualDurations": Map {
+    4 => 0,
     3 => 0,
     2 => 0,
     1 => 0,
   },
   "fiberSelfDurations": Map {
+    4 => 0,
     3 => 0,
     2 => 0,
     1 => 0,
@@ -2674,7 +2720,7 @@ Object {
         Object {
           "changeDescriptions": Array [
             Array [
-              3,
+              4,
               Object {
                 "context": null,
                 "didHooksChange": false,
@@ -2699,6 +2745,10 @@ Object {
               3,
               0,
             ],
+            Array [
+              4,
+              0,
+            ],
           ],
           "fiberSelfDurations": Array [
             Array [
@@ -2713,6 +2763,10 @@ Object {
               3,
               0,
             ],
+            Array [
+              4,
+              0,
+            ],
           ],
           "passiveEffectDuration": 0,
           "priorityLevel": "Immediate",
@@ -2730,9 +2784,9 @@ Object {
         Object {
           "changeDescriptions": Array [
             Array [
-              3,
+              4,
               Object {
-                "context": null,
+                "context": false,
                 "didHooksChange": false,
                 "isFirstMount": false,
                 "props": Array [
@@ -2745,6 +2799,10 @@ Object {
           "duration": 0,
           "effectDuration": null,
           "fiberActualDurations": Array [
+            Array [
+              4,
+              0,
+            ],
             Array [
               3,
               0,
@@ -2759,6 +2817,10 @@ Object {
             ],
           ],
           "fiberSelfDurations": Array [
+            Array [
+              4,
+              0,
+            ],
             Array [
               3,
               0,
@@ -2788,9 +2850,9 @@ Object {
         Object {
           "changeDescriptions": Array [
             Array [
-              3,
+              4,
               Object {
-                "context": null,
+                "context": false,
                 "didHooksChange": true,
                 "isFirstMount": false,
                 "props": Array [],
@@ -2802,13 +2864,13 @@ Object {
           "effectDuration": null,
           "fiberActualDurations": Array [
             Array [
-              3,
+              4,
               0,
             ],
           ],
           "fiberSelfDurations": Array [
             Array [
-              3,
+              4,
               0,
             ],
           ],
@@ -2819,7 +2881,7 @@ Object {
             Object {
               "displayName": "Component",
               "hocDisplayNames": null,
-              "id": 3,
+              "id": 4,
               "key": null,
               "type": 5,
             },
@@ -2828,9 +2890,9 @@ Object {
         Object {
           "changeDescriptions": Array [
             Array [
-              3,
+              4,
               Object {
-                "context": null,
+                "context": false,
                 "didHooksChange": true,
                 "isFirstMount": false,
                 "props": Array [],
@@ -2842,13 +2904,13 @@ Object {
           "effectDuration": null,
           "fiberActualDurations": Array [
             Array [
-              3,
+              4,
               0,
             ],
           ],
           "fiberSelfDurations": Array [
             Array [
-              3,
+              4,
               0,
             ],
           ],
@@ -2859,18 +2921,82 @@ Object {
             Object {
               "displayName": "Component",
               "hocDisplayNames": null,
-              "id": 3,
+              "id": 4,
               "key": null,
               "type": 5,
             },
           ],
         },
         Object {
           "changeDescriptions": Array [
+            Array [
+              4,
+              Object {
+                "context": true,
+                "didHooksChange": false,
+                "isFirstMount": false,
+                "props": Array [],
+                "state": null,
+              },
+            ],
+          ],
+          "duration": 0,
+          "effectDuration": null,
+          "fiberActualDurations": Array [
+            Array [
+              4,
+              0,
+            ],
+            Array [
+              3,
+              0,
+            ],
+            Array [
+              2,
+              0,
+            ],
+            Array [
+              1,
+              0,
+            ],
+          ],
+          "fiberSelfDurations": Array [
+            Array [
+              4,
+              0,
+            ],
             Array [
               3,
+              0,
+            ],
+            Array [
+              2,
+              0,
+            ],
+            Array [
+              1,
+              0,
+            ],
+          ],
+          "passiveEffectDuration": null,
+          "priorityLevel": "Immediate",
+          "timestamp": 0,
+          "updaters": Array [
+            Object {
+              "displayName": "Anonymous",
+              "hocDisplayNames": null,
+              "id": 1,
+              "key": null,
+              "type": 11,
+            },
+          ],
+        },
+        Object {
+          "changeDescriptions": Array [
+            Array [
+              4,
               Object {
-                "context": null,
+                "context": true,
                 "didHooksChange": false,
                 "isFirstMount": false,
                 "props": Array [],
@@ -2881,6 +3007,10 @@ Object {
           "duration": 0,
           "effectDuration": null,
           "fiberActualDurations": Array [
+            Array [
+              4,
+              0,
+            ],
             Array [
               3,
               0,
@@ -2895,6 +3025,10 @@ Object {
             ],
           ],
           "fiberSelfDurations": Array [
+            Array [
+              4,
+              0,
+            ],
             Array [
               3,
               0,
@@ -2973,14 +3107,29 @@ Object {
           0,
           1,
           3,
-          5,
           2,
-          0,
           2,
           0,
+          1,
+          0,
+          4,
+          3,
+          0,
+          1,
           4,
+          5,
           3,
           0,
+          2,
+          0,
+          4,
+          4,
+          0,
+        ],
+        Array [
+          1,
+          1,
+          0,
         ],
         Array [
           1,

filename: packages/react-devtools-shared/src/__tests__/profilingCache-test.js
patch: @@ -311,6 +311,7 @@ describe('ProfilingCache', () => {
 
   it('should properly detect changed hooks', () => {
     const Context = React.createContext(0);
+    const Context2 = React.createContext(0);
 
     function reducer(state, action) {
       switch (action.type) {
@@ -332,6 +333,7 @@ describe('ProfilingCache', () => {
       // This hook's return value may change between renders,
       // but the hook itself isn't stateful.
       React.useContext(Context);
+      React.useContext(Context2);
 
       // These hooks and their dependencies may not change between renders.
       // We're using them to ensure that they don't trigger false positives.
@@ -351,7 +353,9 @@ describe('ProfilingCache', () => {
     utils.act(() =>
       legacyRender(
         <Context.Provider value={true}>
-          <Component count={1} />
+          <Context2.Provider value={true}>
+            <Component count={1} />
+          </Context2.Provider>
         </Context.Provider>,
         container,
       ),
@@ -361,7 +365,9 @@ describe('ProfilingCache', () => {
     utils.act(() =>
       legacyRender(
         <Context.Provider value={true}>
-          <Component count={2} />
+          <Context2.Provider value={true}>
+            <Component count={2} />
+          </Context2.Provider>
         </Context.Provider>,
         container,
       ),
@@ -373,17 +379,29 @@ describe('ProfilingCache', () => {
     // Fourth render has a changed state hook
     utils.act(() => setState('def'));
 
-    // Fifth render has a changed context value, but no changed hook.
-    // Technically, DevTools will miss this "context" change since it only tracks legacy context.
+    // Fifth render has a changed context value for context 1, but no changed hook.
     utils.act(() =>
       legacyRender(
         <Context.Provider value={false}>
-          <Component count={2} />
+          <Context2.Provider value={true}>
+            <Component count={2} />
+          </Context2.Provider>
         </Context.Provider>,
         container,
       ),
     );
 
+    // Sixth render has another changed context value for context 2, but no changed hook.
+    utils.act(() =>
+      legacyRender(
+        <Context.Provider value={false}>
+          <Context2.Provider value={false}>
+            <Component count={2} />
+          </Context2.Provider>
+        </Context.Provider>,
+        container,
+      ),
+    );
     utils.act(() => store.profilerStore.stopProfiling());
 
     const allCommitData = [];
@@ -403,7 +421,7 @@ describe('ProfilingCache', () => {
 
     const rootID = store.roots[0];
 
-    for (let commitIndex = 0; commitIndex < 5; commitIndex++) {
+    for (let commitIndex = 0; commitIndex < 6; commitIndex++) {
       utils.act(() => {
         TestRenderer.create(
           <Validator
@@ -415,12 +433,12 @@ describe('ProfilingCache', () => {
       });
     }
 
-    expect(allCommitData).toHaveLength(5);
+    expect(allCommitData).toHaveLength(6);
 
     // Export and re-import profile data and make sure it is retained.
     utils.exportImportHelper(bridge, store);
 
-    for (let commitIndex = 0; commitIndex < 5; commitIndex++) {
+    for (let commitIndex = 0; commitIndex < 6; commitIndex++) {
       utils.act(() => {
         TestRenderer.create(
           <Validator

filename: packages/react-devtools-shared/src/backend/renderer.js
patch: @@ -960,6 +960,7 @@ export function attach(
 
     return false;
   }
+
   // NOTICE Keep in sync with shouldFilterFiber() and other get*ForFiber methods
   function getElementTypeForFiber(fiber: Fiber): ElementType {
     const {type, tag} = fiber;
@@ -1236,6 +1237,7 @@ export function attach(
 
   function updateContextsForFiber(fiber: Fiber) {
     switch (getElementTypeForFiber(fiber)) {
+      case ElementTypeFunction:
       case ElementTypeClass:
         if (idToContextsMap !== null) {
           const id = getFiberIDThrows(fiber);
@@ -1254,11 +1256,12 @@ export function attach(
   const NO_CONTEXT = {};
 
   function getContextsForFiber(fiber: Fiber): [Object, any] | null {
+    let legacyContext = NO_CONTEXT;
+    let modernContext = NO_CONTEXT;
+
     switch (getElementTypeForFiber(fiber)) {
       case ElementTypeClass:
         const instance = fiber.stateNode;
-        let legacyContext = NO_CONTEXT;
-        let modernContext = NO_CONTEXT;
         if (instance != null) {
           if (
             instance.constructor &&
@@ -1272,6 +1275,13 @@ export function attach(
             }
           }
         }
+        return [legacyContext, modernContext];
+      case ElementTypeFunction:
+        const dependencies = fiber.dependencies;
+        if (dependencies && dependencies.firstContext) {
+          modernContext = dependencies.firstContext;
+        }
+
         return [legacyContext, modernContext];
       default:
         return null;
@@ -1291,31 +1301,50 @@ export function attach(
   }
 
   function getContextChangedKeys(fiber: Fiber): null | boolean | Array<string> {
-    switch (getElementTypeForFiber(fiber)) {
-      case ElementTypeClass:
-        if (idToContextsMap !== null) {
-          const id = getFiberIDThrows(fiber);
-          const prevContexts = idToContextsMap.has(id)
-            ? idToContextsMap.get(id)
-            : null;
-          const nextContexts = getContextsForFiber(fiber);
+    if (idToContextsMap !== null) {
+      const id = getFiberIDThrows(fiber);
+      const prevContexts = idToContextsMap.has(id)
+        ? idToContextsMap.get(id)
+        : null;
+      const nextContexts = getContextsForFiber(fiber);
 
-          if (prevContexts == null || nextContexts == null) {
-            return null;
+      if (prevContexts == null || nextContexts == null) {
+        return null;
+      }
+
+      const [prevLegacyContext, prevModernContext] = prevContexts;
+      const [nextLegacyContext, nextModernContext] = nextContexts;
+
+      switch (getElementTypeForFiber(fiber)) {
+        case ElementTypeClass:
+          if (prevContexts && nextContexts) {
+            if (nextLegacyContext !== NO_CONTEXT) {
+              return getChangedKeys(prevLegacyContext, nextLegacyContext);
+            } else if (nextModernContext !== NO_CONTEXT) {
+              return prevModernContext !== nextModernContext;
+            }
           }
+          break;
+        case ElementTypeFunction:
+          if (nextModernContext !== NO_CONTEXT) {
+            let prevContext = prevModernContext;
+            let nextContext = nextModernContext;
+
+            while (prevContext && nextContext) {
+              if (!is(prevContext.memoizedValue, nextContext.memoizedValue)) {
+                return true;
+              }
 
-          const [prevLegacyContext, prevModernContext] = prevContexts;
-          const [nextLegacyContext, nextModernContext] = nextContexts;
+              prevContext = prevContext.next;
+              nextContext = nextContext.next;
+            }
 
-          if (nextLegacyContext !== NO_CONTEXT) {
-            return getChangedKeys(prevLegacyContext, nextLegacyContext);
-          } else if (nextModernContext !== NO_CONTEXT) {
-            return prevModernContext !== nextModernContext;
+            return false;
           }
-        }
-        break;
-      default:
-        break;
+          break;
+        default:
+          break;
+      }
     }
     return null;
   }
@@ -2859,6 +2888,7 @@ export function attach(
     // Otherwise B has to be current branch.
     return alternate;
   }
+
   // END copied code
 
   function prepareViewAttributeSource(
@@ -3860,6 +3890,7 @@ export function attach(
   // Map of id and its force error status: true (error), false (toggled off),
   // null (do nothing)
   const forceErrorForFiberIDs = new Map();
+
   function shouldErrorFiberAccordingToMap(fiber) {
     if (typeof setErrorHandler !== 'function') {
       throw new Error(
@@ -3924,6 +3955,7 @@ export function attach(
   }
 
   const forceFallbackForSuspenseIDs = new Set();
+
   function shouldSuspendFiberAccordingToSet(fiber) {
     const maybeID = getFiberIDUnsafe(((fiber: any): Fiber));
     return maybeID !== null && forceFallbackForSuspenseIDs.has(maybeID);

filename: packages/react-devtools-shell/src/app/InspectableElements/Contexts.js
patch: @@ -8,7 +8,7 @@
  */
 
 import * as React from 'react';
-import {createContext, Component, useContext} from 'react';
+import {createContext, Component, useContext, useState} from 'react';
 import PropTypes from 'prop-types';
 
 function someNamedFunction() {}
@@ -73,6 +73,43 @@ class LegacyContextConsumer extends Component<any> {
   }
 }
 
+class LegacyContextProviderWithUpdates extends Component<any> {
+  constructor(props) {
+    super(props);
+    this.state = {type: 'desktop'};
+  }
+
+  getChildContext() {
+    return {type: this.state.type};
+  }
+
+  handleChange = event => {
+    this.setState({type: event.target.value});
+  };
+
+  render() {
+    return (
+      <>
+        <LegacyFunctionalContextConsumer />
+        <div>
+          <input value={this.state.type} onChange={this.handleChange} />
+        </div>
+      </>
+    );
+  }
+}
+
+LegacyContextProviderWithUpdates.childContextTypes = {
+  type: PropTypes.string,
+};
+
+function LegacyFunctionalContextConsumer(props, context) {
+  return formatContextForDisplay('LegacyFunctionContextConsumer', context.type);
+}
+LegacyFunctionalContextConsumer.contextTypes = {
+  type: PropTypes.string,
+};
+
 const ModernContext = createContext();
 ModernContext.displayName = 'ModernContext';
 const ArrayContext = createContext(contextData.array);
@@ -105,6 +142,101 @@ function FunctionalContextConsumer() {
   return formatContextForDisplay('FunctionalContextConsumer', value);
 }
 
+const StringContextWithUpdates = createContext({
+  string: contextData.string,
+  setString: (string: string) => {},
+});
+const StringContextWithUpdates2 = createContext({
+  string2: contextData.string,
+  setString2: (string: string) => {},
+});
+
+function FunctionalContextProviderWithContextUpdates() {
+  const [string, setString] = useState(contextData.string);
+  const [string2, setString2] = useState(contextData.string);
+  const value = {string, setString};
+  const value2 = {string2, setString2};
+
+  return (
+    <StringContextWithUpdates.Provider value={value}>
+      <StringContextWithUpdates2.Provider value={value2}>
+        <FunctionalContextConsumerWithContextUpdates />
+      </StringContextWithUpdates2.Provider>
+    </StringContextWithUpdates.Provider>
+  );
+}
+
+function FunctionalContextConsumerWithContextUpdates() {
+  const {string, setString} = useContext(StringContextWithUpdates);
+  const {string2, setString2} = useContext(StringContextWithUpdates2);
+  const [state, setState] = useState('state');
+
+  const handleChange = e => setString(e.target.value);
+  const handleChange2 = e => setString2(e.target.value);
+
+  return (
+    <>
+      {formatContextForDisplay(
+        'FunctionalContextConsumerWithUpdates',
+        `context: ${string}, context 2: ${string2}`,
+      )}
+      <div>
+        context: <input value={string} onChange={handleChange} />
+      </div>
+      <div>
+        context 2: <input value={string2} onChange={handleChange2} />
+      </div>
+      <div>
+        {state}
+        <div>
+          test state:{' '}
+          <input value={state} onChange={e => setState(e.target.value)} />
+        </div>
+      </div>
+    </>
+  );
+}
+
+class ModernClassContextProviderWithUpdates extends Component<any> {
+  constructor(props) {
+    super(props);
+    this.setString = string => {
+      this.setState({string});
+    };
+
+    this.state = {
+      string: contextData.string,
+      setString: this.setString,
+    };
+  }
+
+  render() {
+    return (
+      <StringContextWithUpdates.Provider value={this.state}>
+        <ModernClassContextConsumerWithUpdates />
+      </StringContextWithUpdates.Provider>
+    );
+  }
+}
+
+class ModernClassContextConsumerWithUpdates extends Component<any> {
+  render() {
+    return (
+      <StringContextWithUpdates.Consumer>
+        {({string, setString}) => (
+          <>
+            {formatContextForDisplay(
+              'ModernClassContextConsumerWithUpdates',
+              string,
+            )}
+            <input value={string} onChange={e => setString(e.target.value)} />
+          </>
+        )}
+      </StringContextWithUpdates.Consumer>
+    );
+  }
+}
+
 export default function Contexts() {
   return (
     <div>
@@ -113,13 +245,16 @@ export default function Contexts() {
         <LegacyContextProvider>
           <LegacyContextConsumer />
         </LegacyContextProvider>
+        <LegacyContextProviderWithUpdates />
         <ModernContext.Provider value={contextData}>
           <ModernContext.Consumer>
             {value => formatContextForDisplay('ModernContext.Consumer', value)}
           </ModernContext.Consumer>
           <ModernContextType />
         </ModernContext.Provider>
         <FunctionalContextConsumer />
+        <FunctionalContextProviderWithContextUpdates />
+        <ModernClassContextProviderWithUpdates />
         <ArrayContext.Consumer>
           {value => formatContextForDisplay('ArrayContext.Consumer', value)}
         </ArrayContext.Consumer>