diff --git a/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap b/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap index 7834f6842e..cf7a61c3c8 100644 --- a/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap +++ b/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap @@ -51,6 +51,58 @@ exports[`InspectedElementContext should inspect the currently selected element: } `; +exports[`InspectedElementContext should not re-render a function with hooks if it did not update since it was last inspected: 1: mount 1`] = ` +[root] + ▾ + +`; + +exports[`InspectedElementContext should not re-render a function with hooks if it did not update since it was last inspected: 2: initial render 1`] = ` +{ + "id": 3, + "owners": null, + "context": null, + "events": null, + "hooks": [ + { + "id": 0, + "isStateEditable": true, + "name": "State", + "value": 0, + "subHooks": [] + } + ], + "props": { + "foo": 1, + "bar": "abc" + }, + "state": null +} +`; + +exports[`InspectedElementContext should not re-render a function with hooks if it did not update since it was last inspected: 3: updated state 1`] = ` +{ + "id": 3, + "owners": null, + "context": null, + "events": null, + "hooks": [ + { + "id": 0, + "isStateEditable": true, + "name": "State", + "value": 0, + "subHooks": [] + } + ], + "props": { + "foo": 2, + "bar": "def" + }, + "state": null +} +`; + exports[`InspectedElementContext should poll for updates for the currently selected element: 1: mount 1`] = ` [root] diff --git a/src/__tests__/inspectedElementContext-test.js b/src/__tests__/inspectedElementContext-test.js index bf8d7f8bf7..2ae0b0743c 100644 --- a/src/__tests__/inspectedElementContext-test.js +++ b/src/__tests__/inspectedElementContext-test.js @@ -159,4 +159,94 @@ describe('InspectedElementContext', () => { done(); }); + + it('should not re-render a function with hooks if it did not update since it was last inspected', async done => { + let targetRenderCount = 0; + + const Wrapper = ({ children }) => children; + const Target = React.memo(props => { + targetRenderCount++; + React.useState(0); + return null; + }); + + const container = document.createElement('div'); + utils.act(() => + ReactDOM.render( + + + , + container + ) + ); + expect(store).toMatchSnapshot('1: mount'); + + const id = ((store.getElementIDAtIndex(1): any): number); + + let inspectedElement = null; + + function Suspender({ target }) { + const { read } = React.useContext(InspectedElementContext); + inspectedElement = read(target); + return null; + } + + targetRenderCount = 0; + + let renderer; + await utils.actAsync( + () => + (renderer = TestRenderer.create( + + + + + + )), + 3 + ); + expect(targetRenderCount).toBe(1); + expect(inspectedElement).toMatchSnapshot('2: initial render'); + + const initialInspectedElement = inspectedElement; + + targetRenderCount = 0; + inspectedElement = null; + await utils.actAsync( + () => + renderer.update( + + + + + + ), + 1 + ); + expect(targetRenderCount).toBe(0); + expect(inspectedElement).toEqual(initialInspectedElement); + + targetRenderCount = 0; + + await utils.actAsync(() => + ReactDOM.render( + + + , + container + ) + ); + + // Target should have been rendered once (by ReactDOM) and once by DevTools for inspection. + expect(targetRenderCount).toBe(2); + expect(inspectedElement).toMatchSnapshot('3: updated state'); + + done(); + }); }); diff --git a/src/backend/renderer.js b/src/backend/renderer.js index b670e6c765..67692034be 100644 --- a/src/backend/renderer.js +++ b/src/backend/renderer.js @@ -1102,6 +1102,19 @@ export function attach( if (__DEBUG__) { debug('updateFiberRecursively()', nextFiber, parentFiber); } + + if ( + mostRecentlyInspectedElement !== null && + mostRecentlyInspectedElementID === + getFiberID(getPrimaryFiber(nextFiber)) && + hasDataChanged(prevFiber, nextFiber) + ) { + // If this Fiber has updated, clear cached inspected data. + // If it is inspected again, it may need to be re-run to obtain updated hooks values. + mostRecentlyInspectedElement = null; + mostRecentlyInspectedElementID = null; + } + const shouldIncludeInTree = !shouldFilterFiber(nextFiber); const isSuspense = nextFiber.tag === SuspenseComponent; let shouldResetChildren = false; @@ -1890,18 +1903,41 @@ export function attach( }; } + let mostRecentlyInspectedElementID: number | null = null; + let mostRecentlyInspectedElement: InspectedElement | null = null; + function inspectElement(id: number): InspectedElement | null { - let result = inspectElementRaw(id); - if (result === null) { + // If this element has not been updated since it was last inspected, reuse the last value. + // This avoids re-invoking a function component with hooks. + // TODO We could send a special signal (e.g. true) to avoid serialization too. + if ( + mostRecentlyInspectedElement !== null && + mostRecentlyInspectedElementID === id + ) { + return mostRecentlyInspectedElement; + } + + mostRecentlyInspectedElementID = id; + mostRecentlyInspectedElement = inspectElementRaw(id); + if (mostRecentlyInspectedElement === null) { return null; } - // TODO Review sanitization approach for the below inspectable values. - result.context = cleanForBridge(result.context); - result.events = cleanForBridge(result.events); - result.hooks = cleanForBridge(result.hooks); - result.props = cleanForBridge(result.props); - result.state = cleanForBridge(result.state); - return result; + mostRecentlyInspectedElement.context = cleanForBridge( + mostRecentlyInspectedElement.context + ); + mostRecentlyInspectedElement.events = cleanForBridge( + mostRecentlyInspectedElement.events + ); + mostRecentlyInspectedElement.hooks = cleanForBridge( + mostRecentlyInspectedElement.hooks + ); + mostRecentlyInspectedElement.props = cleanForBridge( + mostRecentlyInspectedElement.props + ); + mostRecentlyInspectedElement.state = cleanForBridge( + mostRecentlyInspectedElement.state + ); + return mostRecentlyInspectedElement; } function logElementToConsole(id) {