From af16f755dc7e0d6e2b4bf79b86c434f4ce0497fe Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 19 Jan 2021 06:51:32 -0800 Subject: [PATCH] Update DevTools to use getCacheForType API (#20548) DevTools was built with a fork of an early idea for how Suspense cache might work. This idea is incompatible with newer APIs like `useTransition` which unfortunately prevented me from making certain UX improvements. This PR swaps out the primary usage of this cache (there are a few) in favor of the newer `unstable_getCacheForType` and `unstable_useCacheRefresh` APIs. We can go back and update the others in follow up PRs. ### Messaging changes I've refactored the way the frontend loads component props/state/etc to hopefully make it better match the Suspense+cache model. Doing this gave up some of the small optimizations I'd added but hopefully the actual performance impact of that is minor and the overall ergonomic improvements of working with the cache API make this worth it. The backend no longer remembers inspected paths. Instead, the frontend sends them every time and the backend sends a response with those paths. I've also added a new "force" parameter that the frontend can use to tell the backend to send a response even if the component hasn't rendered since the last time it asked. (This is used to get data for newly inspected paths.) _Initial inspection..._ ``` front | | back | -- "inspect" (id:1, paths:[], force:true) ---------> | | <------------------------ "inspected" (full-data) -- | ``` _1 second passes with no updates..._ ``` | -- "inspect" (id:1, paths:[], force:false) --------> | | <------------------------ "inspected" (no-change) -- | ``` _User clicks to expand a path, aka hydrate..._ ``` | -- "inspect" (id:1, paths:['foo'], force:true) ----> | | <------------------------ "inspected" (full-data) -- | ``` _1 second passes during which there is an update..._ ``` | -- "inspect" (id:1, paths:['foo'], force:false) ---> | | <----------------- "inspectedElement" (full-data) -- | ``` ### Clear errors/warnings transition Previously this meant there would be a delay after clicking the "clear" button. The UX after this change is much improved. ### Hydrating paths transition I also added a transition to hydration (expanding "dehyrated" paths). ### Better error boundaries I also added a lower-level error boundary in case the new suspense operation ever failed. It provides a better "retry" mechanism (select a new element) so DevTools doesn't become entirely useful. Here I'm intentionally causing an error every time I select an element. ### Improved snapshot tests I also migrated several of the existing snapshot tests to use inline snapshots and added a new serializer for dehydrated props. Inline snapshots are easier to verify and maintain and the new serializer means dehydrated props will be formatted in a way that makes sense rather than being empty (in external snapshots) or super verbose (default inline snapshot format). --- package.json | 2 +- .../inspectedElementContext-test.js.snap | 670 ------------- .../__tests__/dehydratedValueSerializer.js | 39 + ...ntext-test.js => inspectedElement-test.js} | 920 ++++++++++++------ .../__tests__/inspectedElementSerializer.js | 23 +- .../__snapshots__/inspectElement-test.js.snap | 303 ------ .../__tests__/legacy/inspectElement-test.js | 315 ++++-- .../src/__tests__/store-test.js | 26 +- .../src/__tests__/treeContext-test.js | 14 +- .../src/backend/agent.js | 17 +- .../src/backend/legacy/renderer.js | 33 +- .../src/backend/renderer.js | 148 +-- .../src/backend/types.js | 16 +- .../react-devtools-shared/src/backendAPI.js | 283 ++++++ packages/react-devtools-shared/src/bridge.js | 6 +- .../src/devtools/cache.js | 2 + .../src/devtools/store.js | 38 +- .../devtools/views/Components/Components.js | 53 +- .../views/Components/ExpandCollapseToggle.js | 3 + .../views/Components/InspectedElement.js | 16 +- .../Components/InspectedElementContext.js | 454 ++------- .../Components/InspectedElementContextTree.js | 10 +- .../InspectedElementErrorBoundary.css | 21 + .../InspectedElementErrorBoundary.js | 93 ++ .../InspectedElementErrorsAndWarningsTree.js | 80 +- .../Components/InspectedElementHooksTree.js | 34 +- .../Components/InspectedElementPropsTree.js | 8 +- .../Components/InspectedElementStateTree.js | 8 +- .../views/Components/InspectedElementView.js | 107 +- .../devtools/views/Components/KeyValue.css | 4 + .../src/devtools/views/Components/KeyValue.js | 64 +- .../views/Components/LoadingAnimation.css | 5 + .../views/Components/LoadingAnimation.js | 55 ++ .../src/devtools/views/Components/Tree.js | 8 +- .../src/devtools/views/ErrorBoundary.js | 19 +- .../src/inspectedElementCache.js | 220 +++++ .../forks/ReactFeatureFlags.test-renderer.js | 2 +- scripts/jest/config.build-devtools.js | 3 + scripts/jest/preprocessor.js | 4 + 39 files changed, 2054 insertions(+), 2072 deletions(-) delete mode 100644 packages/react-devtools-shared/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap create mode 100644 packages/react-devtools-shared/src/__tests__/dehydratedValueSerializer.js rename packages/react-devtools-shared/src/__tests__/{inspectedElementContext-test.js => inspectedElement-test.js} (75%) delete mode 100644 packages/react-devtools-shared/src/__tests__/legacy/__snapshots__/inspectElement-test.js.snap create mode 100644 packages/react-devtools-shared/src/backendAPI.js create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/InspectedElementErrorBoundary.css create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/InspectedElementErrorBoundary.js create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/LoadingAnimation.css create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/LoadingAnimation.js create mode 100644 packages/react-devtools-shared/src/inspectedElementCache.js diff --git a/package.json b/package.json index deb7b24882da6..bca998508f1dc 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "scripts": { "build": "node ./scripts/rollup/build.js", "build-combined": "node ./scripts/rollup/build-all-release-channels.js", - "build-for-devtools": "cross-env RELEASE_CHANNEL=experimental yarn build react/index,react-dom,react-is,react-debug-tools,scheduler,react-test-renderer,react-refresh", + "build-for-devtools": "cross-env RELEASE_CHANNEL=experimental yarn build-combined react/index,react-dom,react-is,react-debug-tools,scheduler,react-test-renderer,react-refresh", "build-for-devtools-dev": "yarn build-for-devtools --type=NODE_DEV", "build-for-devtools-prod": "yarn build-for-devtools --type=NODE_PROD", "linc": "node ./scripts/tasks/linc.js", diff --git a/packages/react-devtools-shared/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap b/packages/react-devtools-shared/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap deleted file mode 100644 index 8270d0c5b5f1d..0000000000000 --- a/packages/react-devtools-shared/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap +++ /dev/null @@ -1,670 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`InspectedElementContext should dehydrate complex nested values when requested: 1: Initially inspect element 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": null, - "props": { - "set_of_sets": { - "0": {}, - "1": {} - } - }, - "state": null -} -`; - -exports[`InspectedElementContext should dehydrate complex nested values when requested: 2: Inspect props.set_of_sets.0 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": null, - "props": { - "set_of_sets": { - "0": { - "0": 1, - "1": 2, - "2": 3 - }, - "1": {} - } - }, - "state": null -} -`; - -exports[`InspectedElementContext should display complex values of useDebugValue: DisplayedComplexValue 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": [ - { - "id": null, - "isStateEditable": false, - "name": "DebuggableHook", - "value": { - "foo": 2 - }, - "subHooks": [ - { - "id": 0, - "isStateEditable": true, - "name": "State", - "value": 1, - "subHooks": [] - } - ] - } - ], - "props": {}, - "state": null -} -`; - -exports[`InspectedElementContext should include updates for nested values that were previously hydrated: 1: Initially inspect element 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": null, - "props": { - "nestedObject": { - "a": {}, - "c": {} - } - }, - "state": null -} -`; - -exports[`InspectedElementContext should include updates for nested values that were previously hydrated: 2: Inspect props.nestedObject.a 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": null, - "props": { - "nestedObject": { - "a": { - "value": 1, - "b": { - "value": 1 - } - }, - "c": {} - } - }, - "state": null -} -`; - -exports[`InspectedElementContext should include updates for nested values that were previously hydrated: 3: Inspect props.nestedObject.c 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": null, - "props": { - "nestedObject": { - "a": { - "value": 1, - "b": { - "value": 1 - } - }, - "c": { - "value": 1, - "d": { - "value": 1, - "e": {} - } - } - } - }, - "state": null -} -`; - -exports[`InspectedElementContext should include updates for nested values that were previously hydrated: 4: update inspected element 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": null, - "props": { - "nestedObject": { - "a": { - "value": 2, - "b": { - "value": 2 - } - }, - "c": { - "value": 2, - "d": { - "value": 2, - "e": {} - } - } - } - }, - "state": null -} -`; - -exports[`InspectedElementContext should inspect hooks for components that only use context: 1: Inspected element 2 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": [ - { - "id": null, - "isStateEditable": false, - "name": "Context", - "value": true, - "subHooks": [] - } - ], - "props": { - "a": 1, - "b": "abc" - }, - "state": null -} -`; - -exports[`InspectedElementContext should inspect the currently selected element: 1: Inspected element 2 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": [ - { - "id": 0, - "isStateEditable": true, - "name": "State", - "value": 1, - "subHooks": [] - } - ], - "props": { - "a": 1, - "b": "abc" - }, - "state": null -} -`; - -exports[`InspectedElementContext should not consume iterables while inspecting: 1: Inspected element 2 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": null, - "props": { - "prop": {} - }, - "state": null -} -`; - -exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 1: Initially inspect element 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": [ - { - "id": 0, - "isStateEditable": true, - "name": "State", - "value": { - "foo": {} - }, - "subHooks": [] - } - ], - "props": { - "nestedObject": { - "a": {} - } - }, - "state": null -} -`; - -exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 2: Inspect props.nestedObject.a 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": [ - { - "id": 0, - "isStateEditable": true, - "name": "State", - "value": { - "foo": {} - }, - "subHooks": [] - } - ], - "props": { - "nestedObject": { - "a": { - "b": { - "c": {} - } - } - } - }, - "state": null -} -`; - -exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 3: Inspect props.nestedObject.a.b.c 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": [ - { - "id": 0, - "isStateEditable": true, - "name": "State", - "value": { - "foo": {} - }, - "subHooks": [] - } - ], - "props": { - "nestedObject": { - "a": { - "b": { - "c": [ - { - "d": {} - } - ] - } - } - } - }, - "state": null -} -`; - -exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 4: Inspect props.nestedObject.a.b.c.0.d 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": [ - { - "id": 0, - "isStateEditable": true, - "name": "State", - "value": { - "foo": {} - }, - "subHooks": [] - } - ], - "props": { - "nestedObject": { - "a": { - "b": { - "c": [ - { - "d": { - "e": {} - } - } - ] - } - } - } - }, - "state": null -} -`; - -exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 5: Inspect hooks.0.value 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": [ - { - "id": 0, - "isStateEditable": true, - "name": "State", - "value": { - "foo": { - "bar": {} - } - }, - "subHooks": [] - } - ], - "props": { - "nestedObject": { - "a": { - "b": { - "c": [ - { - "d": { - "e": {} - } - } - ] - } - } - } - }, - "state": null -} -`; - -exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 6: Inspect hooks.0.value.foo.bar 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": [ - { - "id": 0, - "isStateEditable": true, - "name": "State", - "value": { - "foo": { - "bar": { - "baz": "hi" - } - } - }, - "subHooks": [] - } - ], - "props": { - "nestedObject": { - "a": { - "b": { - "c": [ - { - "d": { - "e": {} - } - } - ] - } - } - } - }, - "state": null -} -`; - -exports[`InspectedElementContext should not re-render a function with hooks if it did not update since it was last inspected: 1: initial render 1`] = ` -{ - "id": 3, - "owners": null, - "context": null, - "hooks": [ - { - "id": 0, - "isStateEditable": true, - "name": "State", - "value": 0, - "subHooks": [] - } - ], - "props": { - "a": 1, - "b": "abc" - }, - "state": null -} -`; - -exports[`InspectedElementContext should not re-render a function with hooks if it did not update since it was last inspected: 2: updated state 1`] = ` -{ - "id": 3, - "owners": null, - "context": null, - "hooks": [ - { - "id": 0, - "isStateEditable": true, - "name": "State", - "value": 0, - "subHooks": [] - } - ], - "props": { - "a": 2, - "b": "def" - }, - "state": null -} -`; - -exports[`InspectedElementContext should not tear if hydration is requested after an update: 1: Initially inspect element 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": null, - "props": { - "nestedObject": { - "value": 1, - "a": {} - } - }, - "state": null -} -`; - -exports[`InspectedElementContext should not tear if hydration is requested after an update: 2: Inspect props.nestedObject.a 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": null, - "props": { - "nestedObject": { - "value": 2, - "a": { - "value": 2, - "b": { - "value": 2 - } - } - } - }, - "state": null -} -`; - -exports[`InspectedElementContext should poll for updates for the currently selected element: 1: initial render 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": null, - "props": { - "a": 1, - "b": "abc" - }, - "state": null -} -`; - -exports[`InspectedElementContext should poll for updates for the currently selected element: 2: updated state 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": null, - "props": { - "a": 2, - "b": "def" - }, - "state": null -} -`; - -exports[`InspectedElementContext should support complex data types: 1: Inspected element 2 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": null, - "props": { - "anonymous_fn": {}, - "array_buffer": {}, - "array_of_arrays": [ - {} - ], - "big_int": {}, - "bound_fn": {}, - "data_view": {}, - "date": {}, - "fn": {}, - "html_element": {}, - "immutable": { - "0": {}, - "1": {}, - "2": {} - }, - "map": { - "0": {}, - "1": {} - }, - "map_of_maps": { - "0": {}, - "1": {} - }, - "object_of_objects": { - "inner": {} - }, - "object_with_symbol": { - "Symbol(name)": "hello" - }, - "proxy": {}, - "react_element": {}, - "regexp": {}, - "set": { - "0": "abc", - "1": 123 - }, - "set_of_sets": { - "0": {}, - "1": {} - }, - "symbol": {}, - "typed_array": { - "0": 100, - "1": -100, - "2": 0 - } - }, - "state": null -} -`; - -exports[`InspectedElementContext should support custom objects with enumerable properties and getters: 1: Inspected element 2 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": null, - "props": { - "data": { - "_number": 42, - "number": 42 - } - }, - "state": null -} -`; - -exports[`InspectedElementContext should support objects with no prototype: 1: Inspected element 2 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": null, - "props": { - "object": { - "string": "abc", - "number": 123, - "boolean": true - } - }, - "state": null -} -`; - -exports[`InspectedElementContext should support objects with overridden hasOwnProperty: 1: Inspected element 2 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": null, - "props": { - "object": { - "name": "blah", - "hasOwnProperty": true - } - }, - "state": null -} -`; - -exports[`InspectedElementContext should support objects with with inherited keys: 1: Inspected element 2 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": null, - "props": { - "object": { - "123": 3, - "enumerableString": 2, - "Symbol(enumerableSymbol)": 3, - "enumerableStringBase": 1, - "Symbol(enumerableSymbolBase)": 1 - } - }, - "state": null -} -`; - -exports[`InspectedElementContext should support simple data types: 1: Initial inspection 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": null, - "props": { - "boolean_false": false, - "boolean_true": true, - "infinity": null, - "integer_zero": 0, - "integer_one": 1, - "float": 1.23, - "string": "abc", - "string_empty": "", - "nan": null, - "value_null": null - }, - "state": null -} -`; diff --git a/packages/react-devtools-shared/src/__tests__/dehydratedValueSerializer.js b/packages/react-devtools-shared/src/__tests__/dehydratedValueSerializer.js new file mode 100644 index 0000000000000..d8eac57df035c --- /dev/null +++ b/packages/react-devtools-shared/src/__tests__/dehydratedValueSerializer.js @@ -0,0 +1,39 @@ +/** + * 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 + */ + +// test() is part of Jest's serializer API +export function test(maybeDehydratedValue) { + const {meta} = require('react-devtools-shared/src/hydration'); + return ( + maybeDehydratedValue !== null && + typeof maybeDehydratedValue === 'object' && + maybeDehydratedValue.hasOwnProperty(meta.inspectable) && + maybeDehydratedValue[meta.inspected] !== true + ); +} + +// print() is part of Jest's serializer API +export function print(dehydratedValue, serialize, indent) { + const {meta} = require('react-devtools-shared/src/hydration'); + const indentation = Math.max(indent('.').indexOf('.') - 2, 0); + const paddingLeft = ' '.repeat(indentation); + return ( + 'Dehydrated {\n' + + paddingLeft + + ' "preview_short": ' + + dehydratedValue[meta.preview_short] + + ',\n' + + paddingLeft + + ' "preview_long": ' + + dehydratedValue[meta.preview_long] + + ',\n' + + paddingLeft + + '}' + ); +} diff --git a/packages/react-devtools-shared/src/__tests__/inspectedElementContext-test.js b/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js similarity index 75% rename from packages/react-devtools-shared/src/__tests__/inspectedElementContext-test.js rename to packages/react-devtools-shared/src/__tests__/inspectedElement-test.js index c60560302f55f..6a596a8b4a624 100644 --- a/packages/react-devtools-shared/src/__tests__/inspectedElementContext-test.js +++ b/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js @@ -8,16 +8,12 @@ */ import typeof ReactTestRenderer from 'react-test-renderer'; -import type { - CopyInspectedElementPath, - GetInspectedElementPath, - StoreAsGlobal, -} from 'react-devtools-shared/src/devtools/views/Components/InspectedElementContext'; +import {withErrorsOrWarningsIgnored} from 'react-devtools-shared/src/__tests__/utils'; + 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', () => { +describe('InspectedElement', () => { let React; let ReactDOM; let PropTypes; @@ -81,14 +77,26 @@ describe('InspectedElementContext', () => { - - {children} - + + + {children} + + ); + function useInspectedElement(id: number) { + const {inspectedElement} = React.useContext(InspectedElementContext); + return inspectedElement; + } + + function useInspectElementPath(id: number) { + const {inspectPaths} = React.useContext(InspectedElementContext); + return inspectPaths; + } + it('should inspect the currently selected element', async done => { const Example = () => { const [count] = React.useState(1); @@ -105,9 +113,29 @@ describe('InspectedElementContext', () => { let didFinish = false; function Suspender({target}) { - const {getInspectedElement} = React.useContext(InspectedElementContext); - const inspectedElement = getInspectedElement(id); - expect(inspectedElement).toMatchSnapshot(`1: Inspected element ${id}`); + const inspectedElement = useInspectedElement(id); + expect(inspectedElement).toMatchInlineSnapshot(` + Object { + "context": null, + "events": undefined, + "hooks": Array [ + Object { + "id": 0, + "isStateEditable": true, + "name": "State", + "subHooks": Array [], + "value": 1, + }, + ], + "id": 2, + "owners": null, + "props": Object { + "a": 1, + "b": "abc", + }, + "state": null, + } + `); didFinish = true; return null; } @@ -211,8 +239,7 @@ describe('InspectedElementContext', () => { ]; function Suspender({target, shouldHaveLegacyContext}) { - const {getInspectedElement} = React.useContext(InspectedElementContext); - const inspectedElement = getInspectedElement(target); + const inspectedElement = useInspectedElement(target); expect(inspectedElement.context).not.toBe(null); expect(inspectedElement.hasLegacyContext).toBe(shouldHaveLegacyContext); @@ -257,8 +284,7 @@ describe('InspectedElementContext', () => { let inspectedElement = null; function Suspender({target}) { - const {getInspectedElement} = React.useContext(InspectedElementContext); - inspectedElement = getInspectedElement(id); + inspectedElement = useInspectedElement(id); return null; } @@ -273,14 +299,28 @@ describe('InspectedElementContext', () => { , ); }, false); - expect(inspectedElement).toMatchSnapshot('1: initial render'); + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "a": 1, + "b": "abc", + } + `); await utils.actAsync( () => ReactDOM.render(, container), false, ); - inspectedElement = null; + // TODO (cache) + // This test only passes if both the check-for-updates poll AND the test renderer.update() call are included below. + // It seems like either one of the two should be sufficient but: + // 1. Running only check-for-updates schedules a transition that React never renders. + // 2. Running only renderer.update() loads stale data (first props) + + // Wait for our check-for-updates poll to get the new data. + jest.runOnlyPendingTimers(); + await Promise.resolve(); + await utils.actAsync( () => renderer.update( @@ -294,7 +334,12 @@ describe('InspectedElementContext', () => { ), false, ); - expect(inspectedElement).toMatchSnapshot('2: updated state'); + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "a": 2, + "b": "def", + } + `); done(); }); @@ -305,6 +350,7 @@ describe('InspectedElementContext', () => { const Wrapper = ({children}) => children; const Target = React.memo(props => { targetRenderCount++; + // Even though his hook isn't referenced, it's used to observe backend rendering. React.useState(0); return null; }); @@ -324,8 +370,7 @@ describe('InspectedElementContext', () => { let inspectedElement = null; function Suspender({target}) { - const {getInspectedElement} = React.useContext(InspectedElementContext); - inspectedElement = getInspectedElement(target); + inspectedElement = useInspectedElement(target); return null; } @@ -346,7 +391,12 @@ describe('InspectedElementContext', () => { false, ); expect(targetRenderCount).toBe(1); - expect(inspectedElement).toMatchSnapshot('1: initial render'); + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "a": 1, + "b": "abc", + } + `); const initialInspectedElement = inspectedElement; @@ -369,6 +419,7 @@ describe('InspectedElementContext', () => { expect(inspectedElement).toEqual(initialInspectedElement); targetRenderCount = 0; + inspectedElement = null; await utils.actAsync( () => @@ -382,8 +433,26 @@ describe('InspectedElementContext', () => { ); // Target should have been rendered once (by ReactDOM) and once by DevTools for inspection. + await utils.actAsync( + () => + renderer.update( + + + + + , + ), + false, + ); expect(targetRenderCount).toBe(2); - expect(inspectedElement).toMatchSnapshot('2: updated state'); + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "a": 2, + "b": "def", + } + `); done(); }); @@ -426,8 +495,7 @@ describe('InspectedElementContext', () => { let inspectedElement = null; function Suspender({target}) { - const {getInspectedElement} = React.useContext(InspectedElementContext); - inspectedElement = getInspectedElement(target); + inspectedElement = useInspectedElement(target); return null; } @@ -482,8 +550,7 @@ describe('InspectedElementContext', () => { let inspectedElement = null; function Suspender({target}) { - const {getInspectedElement} = React.useContext(InspectedElementContext); - inspectedElement = getInspectedElement(id); + inspectedElement = useInspectedElement(id); return null; } @@ -501,9 +568,6 @@ describe('InspectedElementContext', () => { false, ); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot('1: Initial inspection'); - const {props} = (inspectedElement: any); expect(props.boolean_false).toBe(false); expect(props.boolean_true).toBe(true); @@ -606,8 +670,7 @@ describe('InspectedElementContext', () => { let inspectedElement = null; function Suspender({target}) { - const {getInspectedElement} = React.useContext(InspectedElementContext); - inspectedElement = getInspectedElement(id); + inspectedElement = useInspectedElement(id); return null; } @@ -625,9 +688,6 @@ describe('InspectedElementContext', () => { false, ); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot(`1: Inspected element ${id}`); - const { anonymous_fn, array_buffer, @@ -820,8 +880,7 @@ describe('InspectedElementContext', () => { let inspectedElement = null; function Suspender({target}) { - const {getInspectedElement} = React.useContext(InspectedElementContext); - inspectedElement = getInspectedElement(id); + inspectedElement = useInspectedElement(id); return null; } @@ -839,9 +898,6 @@ describe('InspectedElementContext', () => { false, ); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot(`1: Inspected element ${id}`); - const {prop} = (inspectedElement: any).props; expect(prop[meta.inspectable]).toBe(false); expect(prop[meta.name]).toBe('Generator'); @@ -870,8 +926,7 @@ describe('InspectedElementContext', () => { let inspectedElement = null; function Suspender({target}) { - const {getInspectedElement} = React.useContext(InspectedElementContext); - inspectedElement = getInspectedElement(id); + inspectedElement = useInspectedElement(id); return null; } @@ -889,13 +944,15 @@ describe('InspectedElementContext', () => { false, ); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot(`1: Inspected element ${id}`); - expect(inspectedElement.props.object).toEqual({ - boolean: true, - number: 123, - string: 'abc', - }); + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "object": Object { + "boolean": true, + "number": 123, + "string": "abc", + }, + } + `); done(); }); @@ -918,8 +975,7 @@ describe('InspectedElementContext', () => { let inspectedElement = null; function Suspender({target}) { - const {getInspectedElement} = React.useContext(InspectedElementContext); - inspectedElement = getInspectedElement(id); + inspectedElement = useInspectedElement(id); return null; } @@ -937,12 +993,10 @@ describe('InspectedElementContext', () => { false, ); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot(`1: Inspected element ${id}`); - expect(inspectedElement.props.object).toEqual({ - name: 'blah', - hasOwnProperty: true, - }); + // TRICKY: Don't use toMatchInlineSnapshot() for this test! + // Our snapshot serializer relies on hasOwnProperty() for feature detection. + expect(inspectedElement.props.object.name).toBe('blah'); + expect(inspectedElement.props.object.hasOwnProperty).toBe(true); done(); }); @@ -977,9 +1031,15 @@ describe('InspectedElementContext', () => { let didFinish = false; function Suspender({target}) { - const {getInspectedElement} = React.useContext(InspectedElementContext); - const inspectedElement = getInspectedElement(id); - expect(inspectedElement).toMatchSnapshot(`1: Inspected element ${id}`); + const inspectedElement = useInspectedElement(id); + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "data": Object { + "_number": 42, + "number": 42, + }, + } + `); didFinish = true; return null; } @@ -1075,8 +1135,7 @@ describe('InspectedElementContext', () => { let inspectedElement = null; function Suspender({target}) { - const {getInspectedElement} = React.useContext(InspectedElementContext); - inspectedElement = getInspectedElement(id); + inspectedElement = useInspectedElement(id); return null; } @@ -1094,15 +1153,17 @@ describe('InspectedElementContext', () => { false, ); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot(`1: Inspected element ${id}`); - expect(inspectedElement.props.object).toEqual({ - 123: 3, - 'Symbol(enumerableSymbol)': 3, - 'Symbol(enumerableSymbolBase)': 1, - enumerableString: 2, - enumerableStringBase: 1, - }); + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "object": Object { + "123": 3, + "Symbol(enumerableSymbol)": 3, + "Symbol(enumerableSymbolBase)": 1, + "enumerableString": 2, + "enumerableStringBase": 1, + }, + } + `); done(); }); @@ -1144,96 +1205,155 @@ describe('InspectedElementContext', () => { const id = ((store.getElementIDAtIndex(0): any): number); - let getInspectedElementPath: GetInspectedElementPath = ((null: any): GetInspectedElementPath); let inspectedElement = null; + let inspectElementPath = null; - function Suspender({target}) { - const context = React.useContext(InspectedElementContext); - getInspectedElementPath = context.getInspectedElementPath; - inspectedElement = context.getInspectedElement(target); + function Suspender({path, target}) { + inspectedElement = useInspectedElement(id); + inspectElementPath = useInspectElementPath(id); return null; } - await utils.actAsync( - () => - TestRenderer.create( - - - - - , - ), - false, - ); - expect(getInspectedElementPath).not.toBeNull(); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot('1: Initially inspect element'); + const renderer = TestRenderer.create(null); - inspectedElement = null; - TestUtilsAct(() => { - TestRendererAct(() => { - getInspectedElementPath(id, ['props', 'nestedObject', 'a']); - jest.runOnlyPendingTimers(); - }); - }); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot('2: Inspect props.nestedObject.a'); + async function getInspectedElement() { + await utils.actAsync( + () => + renderer.update( + + + + + , + ), + false, + ); + } - inspectedElement = null; - TestUtilsAct(() => { - TestRendererAct(() => { - getInspectedElementPath(id, ['props', 'nestedObject', 'a', 'b', 'c']); - jest.runOnlyPendingTimers(); - }); - }); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot( - '3: Inspect props.nestedObject.a.b.c', - ); + // Render once to get a handle on inspectElementPath() + await getInspectedElement(); - inspectedElement = null; - TestUtilsAct(() => { - TestRendererAct(() => { - getInspectedElementPath(id, [ - 'props', - 'nestedObject', - 'a', - 'b', - 'c', - 0, - 'd', - ]); - jest.runOnlyPendingTimers(); + async function loadPath(path) { + TestUtilsAct(() => { + TestRendererAct(() => { + inspectElementPath(path); + jest.runOnlyPendingTimers(); + }); }); - }); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot( - '4: Inspect props.nestedObject.a.b.c.0.d', - ); + await getInspectedElement(); + } - inspectedElement = null; - TestUtilsAct(() => { - TestRendererAct(() => { - getInspectedElementPath(id, ['hooks', 0, 'value']); - jest.runOnlyPendingTimers(); - }); - }); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot('5: Inspect hooks.0.value'); + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "nestedObject": Object { + "a": Dehydrated { + "preview_short": {…}, + "preview_long": {b: {…}}, + }, + }, + } + `); + + await loadPath(['props', 'nestedObject', 'a']); + + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "nestedObject": Object { + "a": Object { + "b": Object { + "c": Dehydrated { + "preview_short": Array(1), + "preview_long": [{…}], + }, + }, + }, + }, + } + `); + + await loadPath(['props', 'nestedObject', 'a', 'b', 'c']); + + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "nestedObject": Object { + "a": Object { + "b": Object { + "c": Array [ + Object { + "d": Dehydrated { + "preview_short": {…}, + "preview_long": {e: {…}}, + }, + }, + ], + }, + }, + }, + } + `); + + await loadPath(['props', 'nestedObject', 'a', 'b', 'c', 0, 'd']); + + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "nestedObject": Object { + "a": Object { + "b": Object { + "c": Array [ + Object { + "d": Object { + "e": Object {}, + }, + }, + ], + }, + }, + }, + } + `); - inspectedElement = null; - TestUtilsAct(() => { - TestRendererAct(() => { - getInspectedElementPath(id, ['hooks', 0, 'value', 'foo', 'bar']); - jest.runOnlyPendingTimers(); - }); - }); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot( - '6: Inspect hooks.0.value.foo.bar', - ); + await loadPath(['hooks', 0, 'value']); + + expect(inspectedElement.hooks).toMatchInlineSnapshot(` + Array [ + Object { + "id": 0, + "isStateEditable": true, + "name": "State", + "subHooks": Array [], + "value": Object { + "foo": Object { + "bar": Dehydrated { + "preview_short": {…}, + "preview_long": {baz: "hi"}, + }, + }, + }, + }, + ] + `); + + await loadPath(['hooks', 0, 'value', 'foo', 'bar']); + + expect(inspectedElement.hooks).toMatchInlineSnapshot(` + Array [ + Object { + "id": 0, + "isStateEditable": true, + "name": "State", + "subHooks": Array [], + "value": Object { + "foo": Object { + "bar": Object { + "baz": "hi", + }, + }, + }, + }, + ] + `); done(); }); @@ -1253,42 +1373,79 @@ describe('InspectedElementContext', () => { const id = ((store.getElementIDAtIndex(0): any): number); - let getInspectedElementPath: GetInspectedElementPath = ((null: any): GetInspectedElementPath); let inspectedElement = null; + let inspectElementPath = null; - function Suspender({target}) { - const context = React.useContext(InspectedElementContext); - getInspectedElementPath = context.getInspectedElementPath; - inspectedElement = context.getInspectedElement(target); + function Suspender({path, target}) { + inspectedElement = useInspectedElement(id); + inspectElementPath = useInspectElementPath(id); return null; } - await utils.actAsync( - () => - TestRenderer.create( - - - - - , - ), - false, - ); - expect(getInspectedElementPath).not.toBeNull(); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot('1: Initially inspect element'); + const renderer = TestRenderer.create(null); - inspectedElement = null; - TestUtilsAct(() => { - TestRendererAct(() => { - getInspectedElementPath(id, ['props', 'set_of_sets', 0]); - jest.runOnlyPendingTimers(); + async function getInspectedElement() { + await utils.actAsync( + () => + renderer.update( + + + + + , + ), + false, + ); + } + + // Render once to get a handle on inspectElementPath() + await getInspectedElement(); + + async function loadPath(path) { + TestUtilsAct(() => { + TestRendererAct(() => { + inspectElementPath(path); + jest.runOnlyPendingTimers(); + }); }); - }); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot('2: Inspect props.set_of_sets.0'); + await getInspectedElement(); + } + + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "set_of_sets": Object { + "0": Dehydrated { + "preview_short": Set(3), + "preview_long": Set(3) {1, 2, 3}, + }, + "1": Dehydrated { + "preview_short": Set(3), + "preview_long": Set(3) {"a", "b", "c"}, + }, + }, + } + `); + + await loadPath(['props', 'set_of_sets', 0]); + + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "set_of_sets": Object { + "0": Object { + "0": 1, + "1": 2, + "2": 3, + }, + "1": Object { + "0": "a", + "1": "b", + "2": "c", + }, + }, + } + `); done(); }); @@ -1324,48 +1481,107 @@ describe('InspectedElementContext', () => { const id = ((store.getElementIDAtIndex(0): any): number); - let getInspectedElementPath: GetInspectedElementPath = ((null: any): GetInspectedElementPath); let inspectedElement = null; + let inspectElementPath = null; - function Suspender({target}) { - const context = React.useContext(InspectedElementContext); - getInspectedElementPath = context.getInspectedElementPath; - inspectedElement = context.getInspectedElement(id); + function Suspender({path, target}) { + inspectedElement = useInspectedElement(id); + inspectElementPath = useInspectElementPath(id); return null; } - await utils.actAsync( - () => - TestRenderer.create( - - - - - , - ), - false, - ); - expect(getInspectedElementPath).not.toBeNull(); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot('1: Initially inspect element'); + const renderer = TestRenderer.create(null); - inspectedElement = null; - TestRendererAct(() => { - getInspectedElementPath(id, ['props', 'nestedObject', 'a']); - jest.runOnlyPendingTimers(); - }); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot('2: Inspect props.nestedObject.a'); + async function getInspectedElement() { + await utils.actAsync( + () => + renderer.update( + + + + + , + ), + false, + ); + } - inspectedElement = null; - TestRendererAct(() => { - getInspectedElementPath(id, ['props', 'nestedObject', 'c']); - jest.runOnlyPendingTimers(); - }); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot('3: Inspect props.nestedObject.c'); + // Render once to get a handle on inspectElementPath() + await getInspectedElement(); + + async function loadPath(path) { + TestUtilsAct(() => { + TestRendererAct(() => { + inspectElementPath(path); + jest.runOnlyPendingTimers(); + }); + }); + await getInspectedElement(); + } + + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "nestedObject": Object { + "a": Dehydrated { + "preview_short": {…}, + "preview_long": {b: {…}, value: 1}, + }, + "c": Dehydrated { + "preview_short": {…}, + "preview_long": {d: {…}, value: 1}, + }, + }, + } + `); + + await loadPath(['props', 'nestedObject', 'a']); + + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "nestedObject": Object { + "a": Object { + "b": Object { + "value": 1, + }, + "value": 1, + }, + "c": Object { + "d": Dehydrated { + "preview_short": {…}, + "preview_long": {e: {…}, value: 1}, + }, + "value": 1, + }, + }, + } + `); + + await loadPath(['props', 'nestedObject', 'c']); + + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "nestedObject": Object { + "a": Object { + "b": Object { + "value": 1, + }, + "value": 1, + }, + "c": Object { + "d": Object { + "e": Dehydrated { + "preview_short": {…}, + "preview_long": {value: 1}, + }, + "value": 1, + }, + "value": 1, + }, + }, + } + `); TestRendererAct(() => { TestUtilsAct(() => { @@ -1394,12 +1610,33 @@ describe('InspectedElementContext', () => { }); }); - TestRendererAct(() => { - inspectedElement = null; - jest.advanceTimersByTime(1000); - }); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot('4: update inspected element'); + // Wait for pending poll-for-update and then update inspected element data. + jest.runOnlyPendingTimers(); + await Promise.resolve(); + await getInspectedElement(); + + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "nestedObject": Object { + "a": Object { + "b": Object { + "value": 2, + }, + "value": 2, + }, + "c": Object { + "d": Object { + "e": Dehydrated { + "preview_short": {…}, + "preview_long": {value: 2}, + }, + "value": 2, + }, + "value": 2, + }, + }, + } + `); done(); }); @@ -1427,32 +1664,57 @@ describe('InspectedElementContext', () => { const id = ((store.getElementIDAtIndex(0): any): number); - let getInspectedElementPath: GetInspectedElementPath = ((null: any): GetInspectedElementPath); let inspectedElement = null; + let inspectElementPath = null; - function Suspender({target}) { - const context = React.useContext(InspectedElementContext); - getInspectedElementPath = context.getInspectedElementPath; - inspectedElement = context.getInspectedElement(id); + function Suspender({path, target}) { + inspectedElement = useInspectedElement(id); + inspectElementPath = useInspectElementPath(id); return null; } - await utils.actAsync( - () => - TestRenderer.create( - - - - - , - ), - false, - ); - expect(getInspectedElementPath).not.toBeNull(); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot('1: Initially inspect element'); + const renderer = TestRenderer.create(null); + + async function getInspectedElement() { + await utils.actAsync( + () => + renderer.update( + + + + + , + ), + false, + ); + } + + // Render once to get a handle on inspectElementPath() + await getInspectedElement(); + + async function loadPath(path) { + TestUtilsAct(() => { + TestRendererAct(() => { + inspectElementPath(path); + jest.runOnlyPendingTimers(); + }); + }); + await getInspectedElement(); + } + + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "nestedObject": Object { + "a": Dehydrated { + "preview_short": {…}, + "preview_long": {b: {…}, value: 1}, + }, + "value": 1, + }, + } + `); TestUtilsAct(() => { ReactDOM.render( @@ -1471,16 +1733,21 @@ describe('InspectedElementContext', () => { ); }); - inspectedElement = null; + await loadPath(['props', 'nestedObject', 'a']); - TestRendererAct(() => { - TestUtilsAct(() => { - getInspectedElementPath(id, ['props', 'nestedObject', 'a']); - jest.runOnlyPendingTimers(); - }); - }); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot('2: Inspect props.nestedObject.a'); + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "nestedObject": Object { + "a": Object { + "b": Object { + "value": 2, + }, + "value": 2, + }, + "value": 2, + }, + } + `); done(); }); @@ -1502,9 +1769,29 @@ describe('InspectedElementContext', () => { let didFinish = false; function Suspender({target}) { - const {getInspectedElement} = React.useContext(InspectedElementContext); - const inspectedElement = getInspectedElement(id); - expect(inspectedElement).toMatchSnapshot(`1: Inspected element ${id}`); + const inspectedElement = useInspectedElement(id); + expect(inspectedElement).toMatchInlineSnapshot(` + Object { + "context": null, + "events": undefined, + "hooks": Array [ + Object { + "id": null, + "isStateEditable": false, + "name": "Context", + "subHooks": Array [], + "value": true, + }, + ], + "id": 2, + "owners": null, + "props": Object { + "a": 1, + "b": "abc", + }, + "state": null, + } + `); didFinish = true; return null; } @@ -1554,8 +1841,20 @@ describe('InspectedElementContext', () => { let storeAsGlobal: StoreAsGlobal = ((null: any): StoreAsGlobal); function Suspender({target}) { - const context = React.useContext(InspectedElementContext); - storeAsGlobal = context.storeAsGlobal; + storeAsGlobal = (elementID: number, path: Array) => { + const rendererID = store.getRendererIDForElement(elementID); + if (rendererID !== null) { + const { + storeAsGlobal: storeAsGlobalAPI, + } = require('react-devtools-shared/src/backendAPI'); + storeAsGlobalAPI({ + bridge, + id: elementID, + path, + rendererID, + }); + } + }; return null; } @@ -1572,23 +1871,22 @@ describe('InspectedElementContext', () => { ), false, ); - expect(storeAsGlobal).not.toBeNull(); jest.spyOn(console, 'log').mockImplementation(() => {}); // Should store the whole value (not just the hydrated parts) storeAsGlobal(id, ['props', 'nestedObject']); jest.runOnlyPendingTimers(); - expect(console.log).toHaveBeenCalledWith('$reactTemp1'); - expect(global.$reactTemp1).toBe(nestedObject); + expect(console.log).toHaveBeenCalledWith('$reactTemp0'); + expect(global.$reactTemp0).toBe(nestedObject); console.log.mockReset(); // Should store the nested property specified (not just the outer value) storeAsGlobal(id, ['props', 'nestedObject', 'a', 'b']); jest.runOnlyPendingTimers(); - expect(console.log).toHaveBeenCalledWith('$reactTemp2'); - expect(global.$reactTemp2).toBe(nestedObject.a.b); + expect(console.log).toHaveBeenCalledWith('$reactTemp1'); + expect(global.$reactTemp1).toBe(nestedObject.a.b); done(); }); @@ -1620,8 +1918,20 @@ describe('InspectedElementContext', () => { let copyPath: CopyInspectedElementPath = ((null: any): CopyInspectedElementPath); function Suspender({target}) { - const context = React.useContext(InspectedElementContext); - copyPath = context.copyInspectedElementPath; + copyPath = (elementID: number, path: Array) => { + const rendererID = store.getRendererIDForElement(elementID); + if (rendererID !== null) { + const { + copyInspectedElementPath, + } = require('react-devtools-shared/src/backendAPI'); + copyInspectedElementPath({ + bridge, + id: elementID, + path, + rendererID, + }); + } + }; return null; } @@ -1712,8 +2022,20 @@ describe('InspectedElementContext', () => { let copyPath: CopyInspectedElementPath = ((null: any): CopyInspectedElementPath); function Suspender({target}) { - const context = React.useContext(InspectedElementContext); - copyPath = context.copyInspectedElementPath; + copyPath = (elementID: number, path: Array) => { + const rendererID = store.getRendererIDForElement(elementID); + if (rendererID !== null) { + const { + copyInspectedElementPath, + } = require('react-devtools-shared/src/backendAPI'); + copyInspectedElementPath({ + bridge, + id: elementID, + path, + rendererID, + }); + } + }; return null; } @@ -1761,12 +2083,9 @@ describe('InspectedElementContext', () => { }); it('should display complex values of useDebugValue', async done => { - let getInspectedElementPath: GetInspectedElementPath = ((null: any): GetInspectedElementPath); let inspectedElement = null; function Suspender({target}) { - const context = React.useContext(InspectedElementContext); - getInspectedElementPath = context.getInspectedElementPath; - inspectedElement = context.getInspectedElement(target); + inspectedElement = useInspectedElement(target); return null; } @@ -1803,9 +2122,27 @@ describe('InspectedElementContext', () => { ), false, ); - expect(getInspectedElementPath).not.toBeNull(); - expect(inspectedElement).not.toBeNull(); - expect(inspectedElement).toMatchSnapshot('DisplayedComplexValue'); + expect(inspectedElement.hooks).toMatchInlineSnapshot(` + Array [ + Object { + "id": null, + "isStateEditable": false, + "name": "DebuggableHook", + "subHooks": Array [ + Object { + "id": 0, + "isStateEditable": true, + "name": "State", + "subHooks": Array [], + "value": 1, + }, + ], + "value": Object { + "foo": 2, + }, + }, + ] + `); done(); }); @@ -1825,8 +2162,7 @@ describe('InspectedElementContext', () => { let warnings = null; function Suspender({target}) { - const {getInspectedElement} = React.useContext(InspectedElementContext); - const inspectedElement = getInspectedElement(id); + const inspectedElement = useInspectedElement(id); errors = inspectedElement.errors; warnings = inspectedElement.warnings; return null; @@ -2038,7 +2374,11 @@ describe('InspectedElementContext', () => { ); }); - store.clearErrorsAndWarnings(); + const { + clearErrorsAndWarnings, + } = require('react-devtools-shared/src/backendAPI'); + clearErrorsAndWarnings({bridge, store}); + // Flush events to the renderer. jest.runOnlyPendingTimers(); @@ -2051,7 +2391,7 @@ describe('InspectedElementContext', () => { `); }); - it('can be cleared for a particular Fiber (only errors)', async () => { + 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}`); @@ -2071,7 +2411,14 @@ describe('InspectedElementContext', () => { ); }); - store.clearWarningsForElement(2); + let id = ((store.getElementIDAtIndex(1): any): number); + const rendererID = store.getRendererIDForElement(id); + + const { + clearWarningsForElement, + } = require('react-devtools-shared/src/backendAPI'); + clearWarningsForElement({bridge, id, rendererID}); + // Flush events to the renderer. jest.runOnlyPendingTimers(); @@ -2107,7 +2454,9 @@ describe('InspectedElementContext', () => { ] `); - store.clearWarningsForElement(1); + id = ((store.getElementIDAtIndex(0): any): number); + clearWarningsForElement({bridge, id, rendererID}); + // Flush events to the renderer. jest.runOnlyPendingTimers(); @@ -2139,7 +2488,7 @@ describe('InspectedElementContext', () => { `); }); - it('can be cleared for a particular Fiber (only warnings)', async () => { + 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}`); @@ -2159,7 +2508,14 @@ describe('InspectedElementContext', () => { ); }); - store.clearErrorsForElement(2); + let id = ((store.getElementIDAtIndex(1): any): number); + const rendererID = store.getRendererIDForElement(id); + + const { + clearErrorsForElement, + } = require('react-devtools-shared/src/backendAPI'); + clearErrorsForElement({bridge, id, rendererID}); + // Flush events to the renderer. jest.runOnlyPendingTimers(); @@ -2195,7 +2551,9 @@ describe('InspectedElementContext', () => { ] `); - store.clearErrorsForElement(1); + id = ((store.getElementIDAtIndex(0): any): number); + clearErrorsForElement({bridge, id, rendererID}); + // Flush events to the renderer. jest.runOnlyPendingTimers(); diff --git a/packages/react-devtools-shared/src/__tests__/inspectedElementSerializer.js b/packages/react-devtools-shared/src/__tests__/inspectedElementSerializer.js index b6290daf61a95..a615777421fe3 100644 --- a/packages/react-devtools-shared/src/__tests__/inspectedElementSerializer.js +++ b/packages/react-devtools-shared/src/__tests__/inspectedElementSerializer.js @@ -12,17 +12,14 @@ export function test(maybeInspectedElement) { // print() is part of Jest's serializer API export function print(inspectedElement, serialize, indent) { - return JSON.stringify( - { - id: inspectedElement.id, - owners: inspectedElement.owners, - context: inspectedElement.context, - events: inspectedElement.events, - hooks: inspectedElement.hooks, - props: inspectedElement.props, - state: inspectedElement.state, - }, - null, - 2, - ); + // Don't stringify this object; that would break nested serializers. + return serialize({ + context: inspectedElement.context, + events: inspectedElement.events, + hooks: inspectedElement.hooks, + id: inspectedElement.id, + owners: inspectedElement.owners, + props: inspectedElement.props, + state: inspectedElement.state, + }); } diff --git a/packages/react-devtools-shared/src/__tests__/legacy/__snapshots__/inspectElement-test.js.snap b/packages/react-devtools-shared/src/__tests__/legacy/__snapshots__/inspectElement-test.js.snap deleted file mode 100644 index 1ed17c109bb12..0000000000000 --- a/packages/react-devtools-shared/src/__tests__/legacy/__snapshots__/inspectElement-test.js.snap +++ /dev/null @@ -1,303 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`InspectedElementContext should inspect the currently selected element: 1: Initial inspection 1`] = ` -Object { - "id": 2, - "type": "full-data", - "value": { - "id": 2, - "owners": null, - "context": {}, - "hooks": null, - "props": { - "a": 1, - "b": "abc" - }, - "state": null -}, -} -`; - -exports[`InspectedElementContext should not consume iterables while inspecting: 1: Initial inspection 1`] = ` -Object { - "id": 2, - "type": "full-data", - "value": { - "id": 2, - "owners": null, - "context": {}, - "hooks": null, - "props": { - "iteratable": {} - }, - "state": null -}, -} -`; - -exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 1: Initially inspect element 1`] = ` -Object { - "id": 2, - "type": "full-data", - "value": { - "id": 2, - "owners": null, - "context": {}, - "hooks": null, - "props": { - "nestedObject": { - "a": {} - } - }, - "state": null -}, -} -`; - -exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 2: Inspect props.nestedObject.a 1`] = ` -Object { - "id": 2, - "type": "full-data", - "value": { - "id": 2, - "owners": null, - "context": {}, - "hooks": null, - "props": { - "nestedObject": { - "a": { - "b": { - "c": {} - } - } - } - }, - "state": null -}, -} -`; - -exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 3: Inspect props.nestedObject.a.b.c 1`] = ` -Object { - "id": 2, - "type": "full-data", - "value": { - "id": 2, - "owners": null, - "context": {}, - "hooks": null, - "props": { - "nestedObject": { - "a": { - "b": { - "c": [ - { - "d": {} - } - ] - } - } - } - }, - "state": null -}, -} -`; - -exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 4: Inspect props.nestedObject.a.b.c.0.d 1`] = ` -Object { - "id": 2, - "type": "full-data", - "value": { - "id": 2, - "owners": null, - "context": {}, - "hooks": null, - "props": { - "nestedObject": { - "a": { - "b": { - "c": [ - { - "d": { - "e": {} - } - } - ] - } - } - } - }, - "state": null -}, -} -`; - -exports[`InspectedElementContext should support complex data types: 1: Initial inspection 1`] = ` -Object { - "id": 2, - "type": "full-data", - "value": { - "id": 2, - "owners": null, - "context": {}, - "hooks": null, - "props": { - "anonymous_fn": {}, - "array_buffer": {}, - "array_of_arrays": [ - {} - ], - "big_int": {}, - "bound_fn": {}, - "data_view": {}, - "date": {}, - "fn": {}, - "html_element": {}, - "immutable": { - "0": {}, - "1": {}, - "2": {} - }, - "map": { - "0": {}, - "1": {} - }, - "map_of_maps": { - "0": {}, - "1": {} - }, - "object_of_objects": { - "inner": {} - }, - "react_element": {}, - "regexp": {}, - "set": { - "0": "abc", - "1": 123 - }, - "set_of_sets": { - "0": {}, - "1": {} - }, - "symbol": {}, - "typed_array": { - "0": 100, - "1": -100, - "2": 0 - } - }, - "state": null -}, -} -`; - -exports[`InspectedElementContext should support custom objects with enumerable properties and getters: 1: Initial inspection 1`] = ` -Object { - "id": 2, - "type": "full-data", - "value": { - "id": 2, - "owners": null, - "context": {}, - "hooks": null, - "props": { - "data": { - "_number": 42, - "number": 42 - } - }, - "state": null -}, -} -`; - -exports[`InspectedElementContext should support objects with no prototype: 1: Initial inspection 1`] = ` -Object { - "id": 2, - "type": "full-data", - "value": { - "id": 2, - "owners": null, - "context": {}, - "hooks": null, - "props": { - "object": { - "string": "abc", - "number": 123, - "boolean": true - } - }, - "state": null -}, -} -`; - -exports[`InspectedElementContext should support objects with overridden hasOwnProperty: 1: Initial inspection 1`] = ` -Object { - "id": 2, - "type": "full-data", - "value": { - "id": 2, - "owners": null, - "context": {}, - "hooks": null, - "props": { - "object": { - "name": "blah", - "hasOwnProperty": true - } - }, - "state": null -}, -} -`; - -exports[`InspectedElementContext should support objects with with inherited keys: 1: Initial inspection 1`] = ` -Object { - "id": 2, - "type": "full-data", - "value": { - "id": 2, - "owners": null, - "context": {}, - "hooks": null, - "props": { - "data": { - "123": 3, - "enumerableString": 2, - "Symbol(enumerableSymbol)": 3, - "enumerableStringBase": 1, - "Symbol(enumerableSymbolBase)": 1 - } - }, - "state": null -}, -} -`; - -exports[`InspectedElementContext should support simple data types: 1: Initial inspection 1`] = ` -Object { - "id": 2, - "type": "full-data", - "value": { - "id": 2, - "owners": null, - "context": {}, - "hooks": null, - "props": { - "boolean_false": false, - "boolean_true": true, - "infinity": null, - "integer_zero": 0, - "integer_one": 1, - "float": 1.23, - "string": "abc", - "string_empty": "", - "nan": null, - "value_null": null - }, - "state": null -}, -} -`; diff --git a/packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js b/packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js index 6ccff70c77772..05d2bdcbeb6eb 100644 --- a/packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js +++ b/packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js @@ -7,71 +7,50 @@ * @flow */ -import type {InspectedElementPayload} from 'react-devtools-shared/src/backend/types'; -import type {DehydratedData} from 'react-devtools-shared/src/devtools/views/Components/types'; import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; import type Store from 'react-devtools-shared/src/devtools/store'; describe('InspectedElementContext', () => { let React; let ReactDOM; - let hydrate; - let meta; let bridge: FrontendBridge; let store: Store; + let backendAPI; + const act = (callback: Function) => { callback(); jest.runAllTimers(); // Flush Bridge operations }; - function dehydrateHelper( - dehydratedData: DehydratedData | null, - ): Object | null { - if (dehydratedData !== null) { - return hydrate( - dehydratedData.data, - dehydratedData.cleaned, - dehydratedData.unserializable, - ); - } else { - return null; - } - } - async function read( id: number, - path?: Array, + inspectedPaths?: Object = {}, ): Promise { - return new Promise((resolve, reject) => { - const rendererID = ((store.getRendererIDForElement(id): any): number); - - const onInspectedElement = (payload: InspectedElementPayload) => { - bridge.removeListener('inspectedElement', onInspectedElement); - - if (payload.type === 'full-data' && payload.value !== null) { - payload.value.context = dehydrateHelper(payload.value.context); - payload.value.props = dehydrateHelper(payload.value.props); - payload.value.state = dehydrateHelper(payload.value.state); - } - - resolve(payload); - }; + const rendererID = ((store.getRendererIDForElement(id): any): number); + const promise = backendAPI + .inspectElement({ + bridge, + forceUpdate: true, + id, + inspectedPaths, + rendererID, + }) + .then(data => + backendAPI.convertInspectedElementBackendToFrontend(data.value), + ); - bridge.addListener('inspectedElement', onInspectedElement); - bridge.send('inspectElement', {id, path, rendererID}); + jest.runOnlyPendingTimers(); - jest.runOnlyPendingTimers(); - }); + return promise; } beforeEach(() => { bridge = global.bridge; store = global.store; - hydrate = require('react-devtools-shared/src/hydration').hydrate; - meta = require('react-devtools-shared/src/hydration').meta; + backendAPI = require('react-devtools-shared/src/backendAPI'); // Redirect all React/ReactDOM requires to the v15 UMD. // We use the UMD because Jest doesn't enable us to mock deep imports (e.g. "react/lib/Something"). @@ -94,7 +73,20 @@ describe('InspectedElementContext', () => { const id = ((store.getElementIDAtIndex(0): any): number); const inspectedElement = await read(id); - expect(inspectedElement).toMatchSnapshot('1: Initial inspection'); + expect(inspectedElement).toMatchInlineSnapshot(` + Object { + "context": Object {}, + "events": undefined, + "hooks": null, + "id": 2, + "owners": null, + "props": Object { + "a": 1, + "b": "abc", + }, + "state": null, + } + `); done(); }); @@ -124,20 +116,29 @@ describe('InspectedElementContext', () => { const id = ((store.getElementIDAtIndex(0): any): number); const inspectedElement = await read(id); - expect(inspectedElement).toMatchSnapshot('1: Initial inspection'); - - const {props} = inspectedElement.value; - expect(props.boolean_false).toBe(false); - expect(props.boolean_true).toBe(true); - expect(Number.isFinite(props.infinity)).toBe(false); - expect(props.integer_zero).toEqual(0); - expect(props.integer_one).toEqual(1); - expect(props.float).toEqual(1.23); - expect(props.string).toEqual('abc'); - expect(props.string_empty).toEqual(''); - expect(props.nan).toBeNaN(); - expect(props.value_null).toBeNull(); - expect(props.value_undefined).toBeUndefined(); + expect(inspectedElement).toMatchInlineSnapshot(` + Object { + "context": Object {}, + "events": undefined, + "hooks": null, + "id": 2, + "owners": null, + "props": Object { + "boolean_false": false, + "boolean_true": true, + "float": 1.23, + "infinity": Infinity, + "integer_one": 1, + "integer_zero": 0, + "nan": NaN, + "string": "abc", + "string_empty": "", + "value_null": null, + "value_undefined": undefined, + }, + "state": null, + } + `); done(); }); @@ -211,8 +212,6 @@ describe('InspectedElementContext', () => { const id = ((store.getElementIDAtIndex(0): any): number); const inspectedElement = await read(id); - expect(inspectedElement).toMatchSnapshot('1: Initial inspection'); - const { anonymous_fn, array_buffer, @@ -233,7 +232,9 @@ describe('InspectedElementContext', () => { set_of_sets, symbol, typed_array, - } = inspectedElement.value.props; + } = inspectedElement.props; + + const {meta} = require('react-devtools-shared/src/hydration'); expect(anonymous_fn[meta.inspectable]).toBe(false); expect(anonymous_fn[meta.name]).toBe('function'); @@ -360,12 +361,15 @@ describe('InspectedElementContext', () => { const id = ((store.getElementIDAtIndex(0): any): number); const inspectedElement = await read(id); - expect(inspectedElement).toMatchSnapshot('1: Initial inspection'); - expect(inspectedElement.value.props.object).toEqual({ - boolean: true, - number: 123, - string: 'abc', - }); + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "object": Object { + "boolean": true, + "number": 123, + "string": "abc", + }, + } + `); done(); }); @@ -388,11 +392,10 @@ describe('InspectedElementContext', () => { const id = ((store.getElementIDAtIndex(0): any): number); const inspectedElement = await read(id); - expect(inspectedElement).toMatchSnapshot('1: Initial inspection'); - expect(inspectedElement.value.props.object).toEqual({ - name: 'blah', - hasOwnProperty: true, - }); + // TRICKY: Don't use toMatchInlineSnapshot() for this test! + // Our snapshot serializer relies on hasOwnProperty() for feature detection. + expect(inspectedElement.props.object.name).toBe('blah'); + expect(inspectedElement.props.object.hasOwnProperty).toBe(true); done(); }); @@ -417,7 +420,22 @@ describe('InspectedElementContext', () => { const id = ((store.getElementIDAtIndex(0): any): number); const inspectedElement = await read(id); - expect(inspectedElement).toMatchSnapshot('1: Initial inspection'); + expect(inspectedElement).toMatchInlineSnapshot(` + Object { + "context": Object {}, + "events": undefined, + "hooks": null, + "id": 2, + "owners": null, + "props": Object { + "iteratable": Dehydrated { + "preview_short": Generator, + "preview_long": Generator, + }, + }, + "state": null, + } + `); // Inspecting should not consume the iterable. expect(iteratable.next().value).toEqual(1); @@ -457,7 +475,22 @@ describe('InspectedElementContext', () => { const id = ((store.getElementIDAtIndex(0): any): number); const inspectedElement = await read(id); - expect(inspectedElement).toMatchSnapshot('1: Initial inspection'); + expect(inspectedElement).toMatchInlineSnapshot(` + Object { + "context": Object {}, + "events": undefined, + "hooks": null, + "id": 2, + "owners": null, + "props": Object { + "data": Object { + "_number": 42, + "number": 42, + }, + }, + "state": null, + } + `); done(); }); @@ -532,7 +565,25 @@ describe('InspectedElementContext', () => { const id = ((store.getElementIDAtIndex(0): any): number); const inspectedElement = await read(id); - expect(inspectedElement).toMatchSnapshot('1: Initial inspection'); + expect(inspectedElement).toMatchInlineSnapshot(` + Object { + "context": Object {}, + "events": undefined, + "hooks": null, + "id": 2, + "owners": null, + "props": Object { + "data": Object { + "123": 3, + "Symbol(enumerableSymbol)": 3, + "Symbol(enumerableSymbolBase)": 1, + "enumerableString": 2, + "enumerableStringBase": 1, + }, + }, + "state": null, + } + `); done(); }); @@ -564,28 +615,75 @@ describe('InspectedElementContext', () => { const id = ((store.getElementIDAtIndex(0): any): number); let inspectedElement = await read(id); - expect(inspectedElement).toMatchSnapshot('1: Initially inspect element'); - - inspectedElement = await read(id, ['props', 'nestedObject', 'a']); - expect(inspectedElement).toMatchSnapshot('2: Inspect props.nestedObject.a'); + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "nestedObject": Object { + "a": Dehydrated { + "preview_short": {…}, + "preview_long": {b: {…}}, + }, + }, + } + `); + + inspectedElement = await read(id, {props: {nestedObject: {a: {}}}}); + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "nestedObject": Object { + "a": Object { + "b": Object { + "c": Dehydrated { + "preview_short": Array(1), + "preview_long": [{…}], + }, + }, + }, + }, + } + `); - inspectedElement = await read(id, ['props', 'nestedObject', 'a', 'b', 'c']); - expect(inspectedElement).toMatchSnapshot( - '3: Inspect props.nestedObject.a.b.c', - ); + inspectedElement = await read(id, { + props: {nestedObject: {a: {b: {c: {}}}}}, + }); + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "nestedObject": Object { + "a": Object { + "b": Object { + "c": Array [ + Object { + "d": Dehydrated { + "preview_short": {…}, + "preview_long": {e: {…}}, + }, + }, + ], + }, + }, + }, + } + `); - inspectedElement = await read(id, [ - 'props', - 'nestedObject', - 'a', - 'b', - 'c', - 0, - 'd', - ]); - expect(inspectedElement).toMatchSnapshot( - '4: Inspect props.nestedObject.a.b.c.0.d', - ); + inspectedElement = await read(id, { + props: {nestedObject: {a: {b: {c: {0: {d: {}}}}}}}, + }); + expect(inspectedElement.props).toMatchInlineSnapshot(` + Object { + "nestedObject": Object { + "a": Object { + "b": Object { + "c": Array [ + Object { + "d": Object { + "e": Object {}, + }, + }, + ], + }, + }, + }, + } + `); done(); }); @@ -619,28 +717,30 @@ describe('InspectedElementContext', () => { spyOn(console, 'log').and.callFake(logSpy); // Should store the whole value (not just the hydrated parts) - bridge.send('storeAsGlobal', { - count: 1, + backendAPI.storeAsGlobal({ + bridge, id, path: ['props', 'nestedObject'], rendererID, }); + jest.runOnlyPendingTimers(); - expect(logSpy).toHaveBeenCalledWith('$reactTemp1'); - expect(global.$reactTemp1).toBe(nestedObject); + expect(logSpy).toHaveBeenCalledWith('$reactTemp0'); + expect(global.$reactTemp0).toBe(nestedObject); logSpy.mockReset(); // Should store the nested property specified (not just the outer value) - bridge.send('storeAsGlobal', { - count: 2, + backendAPI.storeAsGlobal({ + bridge, id, path: ['props', 'nestedObject', 'a', 'b'], rendererID, }); + jest.runOnlyPendingTimers(); - expect(logSpy).toHaveBeenCalledWith('$reactTemp2'); - expect(global.$reactTemp2).toBe(nestedObject.a.b); + expect(logSpy).toHaveBeenCalledWith('$reactTemp1'); + expect(global.$reactTemp1).toBe(nestedObject.a.b); }); it('should enable inspected values to be copied to the clipboard', () => { @@ -669,11 +769,13 @@ describe('InspectedElementContext', () => { const rendererID = ((store.getRendererIDForElement(id): any): number); // Should copy the whole value (not just the hydrated parts) - bridge.send('copyElementPath', { + backendAPI.copyInspectedElementPath({ + bridge, id, path: ['props', 'nestedObject'], rendererID, }); + jest.runOnlyPendingTimers(); expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1); expect(global.mockClipboardCopy).toHaveBeenCalledWith( @@ -683,11 +785,13 @@ describe('InspectedElementContext', () => { global.mockClipboardCopy.mockReset(); // Should copy the nested property specified (not just the outer value) - bridge.send('copyElementPath', { + backendAPI.copyInspectedElementPath({ + bridge, id, path: ['props', 'nestedObject', 'a', 'b'], rendererID, }); + jest.runOnlyPendingTimers(); expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1); expect(global.mockClipboardCopy).toHaveBeenCalledWith( @@ -745,7 +849,8 @@ describe('InspectedElementContext', () => { const rendererID = ((store.getRendererIDForElement(id): any): number); // Should copy the whole value (not just the hydrated parts) - bridge.send('copyElementPath', { + backendAPI.copyInspectedElementPath({ + bridge, id, path: ['props'], rendererID, @@ -756,7 +861,8 @@ describe('InspectedElementContext', () => { global.mockClipboardCopy.mockReset(); // Should copy the nested property specified (not just the outer value) - bridge.send('copyElementPath', { + backendAPI.copyInspectedElementPath({ + bridge, id, path: ['props', 'bigInt'], rendererID, @@ -770,7 +876,8 @@ describe('InspectedElementContext', () => { global.mockClipboardCopy.mockReset(); // Should copy the nested property specified (not just the outer value) - bridge.send('copyElementPath', { + backendAPI.copyInspectedElementPath({ + bridge, id, path: ['props', 'typedArray'], rendererID, diff --git a/packages/react-devtools-shared/src/__tests__/store-test.js b/packages/react-devtools-shared/src/__tests__/store-test.js index d219f3cba7def..d5b9c3cbc9213 100644 --- a/packages/react-devtools-shared/src/__tests__/store-test.js +++ b/packages/react-devtools-shared/src/__tests__/store-test.js @@ -12,12 +12,14 @@ describe('Store', () => { let ReactDOM; let agent; let act; + let bridge; let getRendererID; let store; let withErrorsOrWarningsIgnored; beforeEach(() => { agent = global.agent; + bridge = global.bridge; store = global.store; React = require('react'); @@ -1159,7 +1161,11 @@ describe('Store', () => { ✕⚠ `); - store.clearErrorsAndWarnings(); + const { + clearErrorsAndWarnings, + } = require('react-devtools-shared/src/backendAPI'); + clearErrorsAndWarnings({bridge, store}); + // flush events to the renderer jest.runAllTimers(); @@ -1196,7 +1202,14 @@ describe('Store', () => { ✕⚠ `); - store.clearWarningsForElement(2); + const id = ((store.getElementIDAtIndex(1): any): number); + const rendererID = store.getRendererIDForElement(id); + + const { + clearWarningsForElement, + } = require('react-devtools-shared/src/backendAPI'); + clearWarningsForElement({bridge, id, rendererID}); + // Flush events to the renderer. jest.runAllTimers(); @@ -1234,7 +1247,14 @@ describe('Store', () => { ✕⚠ `); - store.clearErrorsForElement(2); + const id = ((store.getElementIDAtIndex(1): any): number); + const rendererID = store.getRendererIDForElement(id); + + const { + clearErrorsForElement, + } = require('react-devtools-shared/src/backendAPI'); + clearErrorsForElement({bridge, id, rendererID}); + // Flush events to the renderer. jest.runAllTimers(); diff --git a/packages/react-devtools-shared/src/__tests__/treeContext-test.js b/packages/react-devtools-shared/src/__tests__/treeContext-test.js index 8c057fb720807..36f615c1155b6 100644 --- a/packages/react-devtools-shared/src/__tests__/treeContext-test.js +++ b/packages/react-devtools-shared/src/__tests__/treeContext-test.js @@ -1441,20 +1441,28 @@ describe('TreeListContext', () => { }); describe('inline errors/warnings state', () => { + const { + clearErrorsAndWarnings: clearErrorsAndWarningsAPI, + clearErrorsForElement: clearErrorsForElementAPI, + clearWarningsForElement: clearWarningsForElementAPI, + } = require('react-devtools-shared/src/backendAPI'); + function clearAllErrors() { - utils.act(() => store.clearErrorsAndWarnings()); + utils.act(() => clearErrorsAndWarningsAPI({bridge, store})); // flush events to the renderer jest.runAllTimers(); } function clearErrorsForElement(id) { - utils.act(() => store.clearErrorsForElement(id)); + const rendererID = store.getRendererIDForElement(id); + utils.act(() => clearErrorsForElementAPI({bridge, id, rendererID})); // flush events to the renderer jest.runAllTimers(); } function clearWarningsForElement(id) { - utils.act(() => store.clearWarningsForElement(id)); + const rendererID = store.getRendererIDForElement(id); + utils.act(() => clearWarningsForElementAPI({bridge, id, rendererID})); // flush events to the renderer jest.runAllTimers(); } diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index 326b0fb2e340f..e570febf5d67e 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -70,8 +70,10 @@ type CopyElementParams = {| type InspectElementParams = {| id: number, - path?: Array, + inspectedPaths: Object, + forceUpdate: boolean, rendererID: number, + requestID: number, |}; type OverrideHookParams = {| @@ -328,12 +330,21 @@ export default class Agent extends EventEmitter<{| } }; - inspectElement = ({id, path, rendererID}: InspectElementParams) => { + inspectElement = ({ + id, + inspectedPaths, + forceUpdate, + rendererID, + requestID, + }: InspectElementParams) => { const renderer = this._rendererInterfaces[rendererID]; if (renderer == null) { console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); } else { - this._bridge.send('inspectedElement', renderer.inspectElement(id, path)); + this._bridge.send( + 'inspectedElement', + renderer.inspectElement(requestID, id, inspectedPaths, forceUpdate), + ); // When user selects an element, stop trying to restore the selection, // and instead remember the current selection for the next reload. diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index 90f0f3ad12b46..63c9b1660e75e 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -584,25 +584,12 @@ export function attach( } let currentlyInspectedElementID: number | null = null; - let currentlyInspectedPaths: Object = {}; - - // Track the intersection of currently inspected paths, - // so that we can send their data along if the element is re-rendered. - function mergeInspectedPaths(path: Array) { - let current = currentlyInspectedPaths; - path.forEach(key => { - if (!current[key]) { - current[key] = {}; - } - current = current[key]; - }); - } - function createIsPathAllowed(key: string) { + function createIsPathAllowed(key: string, inspectedPaths: Object) { // This function helps prevent previously-inspected paths from being dehydrated in updates. // This is important to avoid a bad user experience where expanded toggles collapse on update. return function isPathAllowed(path: Array): boolean { - let current = currentlyInspectedPaths[key]; + let current = inspectedPaths[key]; if (!current) { return false; } @@ -691,26 +678,23 @@ export function attach( } function inspectElement( + requestID: number, id: number, - path?: Array, + inspectedPaths: Object, ): InspectedElementPayload { if (currentlyInspectedElementID !== id) { currentlyInspectedElementID = id; - currentlyInspectedPaths = {}; } const inspectedElement = inspectElementRaw(id); if (inspectedElement === null) { return { id, + responseID: requestID, type: 'not-found', }; } - if (path != null) { - mergeInspectedPaths(path); - } - // Any time an inspected element has an update, // we should update the selected $r value as wel. // Do this before dehyration (cleanForBridge). @@ -718,19 +702,20 @@ export function attach( inspectedElement.context = cleanForBridge( inspectedElement.context, - createIsPathAllowed('context'), + createIsPathAllowed('context', inspectedPaths), ); inspectedElement.props = cleanForBridge( inspectedElement.props, - createIsPathAllowed('props'), + createIsPathAllowed('props', inspectedPaths), ); inspectedElement.state = cleanForBridge( inspectedElement.state, - createIsPathAllowed('state'), + createIsPathAllowed('state', inspectedPaths), ); return { id, + responseID: requestID, type: 'full-data', value: inspectedElement, }; diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index b942b7561b7b8..ee90dfee688ac 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -2718,7 +2718,6 @@ export function attach( let mostRecentlyInspectedElement: InspectedElement | null = null; let hasElementUpdatedSinceLastInspected: boolean = false; - let currentlyInspectedPaths: Object = {}; function isMostRecentlyInspectedElementCurrent(id: number): boolean { return ( @@ -2728,21 +2727,10 @@ export function attach( ); } - // Track the intersection of currently inspected paths, - // so that we can send their data along if the element is re-rendered. - function mergeInspectedPaths(path: Array) { - let current = currentlyInspectedPaths; - path.forEach(key => { - if (!current[key]) { - current[key] = {}; - } - current = current[key]; - }); - } - function createIsPathAllowed( key: string | null, secondaryCategory: 'hooks' | null, + inspectedPaths: Object, ) { // This function helps prevent previously-inspected paths from being dehydrated in updates. // This is important to avoid a bad user experience where expanded toggles collapse on update. @@ -2767,8 +2755,7 @@ export function attach( break; } - let current = - key === null ? currentlyInspectedPaths : currentlyInspectedPaths[key]; + let current = key === null ? inspectedPaths : inspectedPaths[key]; if (!current) { return false; } @@ -2863,97 +2850,66 @@ export function attach( } function inspectElement( + requestID: number, id: number, - path?: Array, + inspectedPaths: Object, + forceUpdate: boolean, ): InspectedElementPayload { - const isCurrent = isMostRecentlyInspectedElementCurrent(id); + const isCurrent = !forceUpdate && isMostRecentlyInspectedElementCurrent(id); if (isCurrent) { - if (path != null) { - mergeInspectedPaths(path); - - let secondaryCategory = null; - if (path[0] === 'hooks') { - secondaryCategory = 'hooks'; - } - - // If this element has not been updated since it was last inspected, - // we can just return the subset of data in the newly-inspected path. - return { - id, - type: 'hydrated-path', - path, - value: cleanForBridge( - getInObject( - ((mostRecentlyInspectedElement: any): InspectedElement), - path, - ), - createIsPathAllowed(null, secondaryCategory), - path, - ), - }; - } else { - // If this element has not been updated since it was last inspected, we don't need to re-run it. - // Instead we can just return the ID to indicate that it has not changed. - return { - id, - type: 'no-change', - }; - } - } else { - hasElementUpdatedSinceLastInspected = false; - - if ( - mostRecentlyInspectedElement === null || - mostRecentlyInspectedElement.id !== id - ) { - currentlyInspectedPaths = {}; - } - - mostRecentlyInspectedElement = inspectElementRaw(id); - if (mostRecentlyInspectedElement === null) { - return { - id, - type: 'not-found', - }; - } - - if (path != null) { - mergeInspectedPaths(path); - } - - // Any time an inspected element has an update, - // we should update the selected $r value as wel. - // Do this before dehydration (cleanForBridge). - updateSelectedElement(mostRecentlyInspectedElement); + // If this element has not been updated since it was last inspected, we don't need to return it. + // Instead we can just return the ID to indicate that it has not changed. + return { + id, + responseID: requestID, + type: 'no-change', + }; + } - // Clone before cleaning so that we preserve the full data. - // This will enable us to send patches without re-inspecting if hydrated paths are requested. - // (Reducing how often we shallow-render is a better DX for function components that use hooks.) - const cleanedInspectedElement = {...mostRecentlyInspectedElement}; - cleanedInspectedElement.context = cleanForBridge( - cleanedInspectedElement.context, - createIsPathAllowed('context', null), - ); - cleanedInspectedElement.hooks = cleanForBridge( - cleanedInspectedElement.hooks, - createIsPathAllowed('hooks', 'hooks'), - ); - cleanedInspectedElement.props = cleanForBridge( - cleanedInspectedElement.props, - createIsPathAllowed('props', null), - ); - cleanedInspectedElement.state = cleanForBridge( - cleanedInspectedElement.state, - createIsPathAllowed('state', null), - ); + hasElementUpdatedSinceLastInspected = false; + mostRecentlyInspectedElement = inspectElementRaw(id); + if (mostRecentlyInspectedElement === null) { return { id, - type: 'full-data', - value: cleanedInspectedElement, + responseID: requestID, + type: 'not-found', }; } + + // Any time an inspected element has an update, + // we should update the selected $r value as wel. + // Do this before dehydration (cleanForBridge). + updateSelectedElement(mostRecentlyInspectedElement); + + // Clone before cleaning so that we preserve the full data. + // This will enable us to send patches without re-inspecting if hydrated paths are requested. + // (Reducing how often we shallow-render is a better DX for function components that use hooks.) + const cleanedInspectedElement = {...mostRecentlyInspectedElement}; + cleanedInspectedElement.context = cleanForBridge( + cleanedInspectedElement.context, + createIsPathAllowed('context', null, inspectedPaths), + ); + cleanedInspectedElement.hooks = cleanForBridge( + cleanedInspectedElement.hooks, + createIsPathAllowed('hooks', 'hooks', inspectedPaths), + ); + cleanedInspectedElement.props = cleanForBridge( + cleanedInspectedElement.props, + createIsPathAllowed('props', null, inspectedPaths), + ); + cleanedInspectedElement.state = cleanForBridge( + cleanedInspectedElement.state, + createIsPathAllowed('state', null, inspectedPaths), + ); + + return { + id, + responseID: requestID, + type: 'full-data', + value: cleanedInspectedElement, + }; } function logElementToConsole(id) { diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index 4cecf59df2983..09b32aa1ceef3 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -257,34 +257,28 @@ export type InspectedElement = {| export const InspectElementFullDataType = 'full-data'; export const InspectElementNoChangeType = 'no-change'; export const InspectElementNotFoundType = 'not-found'; -export const InspectElementHydratedPathType = 'hydrated-path'; type InspectElementFullData = {| id: number, + responseID: number, type: 'full-data', value: InspectedElement, |}; -type InspectElementHydratedPath = {| - id: number, - type: 'hydrated-path', - path: Array, - value: any, -|}; - type InspectElementNoChange = {| id: number, + responseID: number, type: 'no-change', |}; type InspectElementNotFound = {| id: number, + responseID: number, type: 'not-found', |}; export type InspectedElementPayload = | InspectElementFullData - | InspectElementHydratedPath | InspectElementNoChange | InspectElementNotFound; @@ -319,8 +313,10 @@ export type RendererInterface = { handleCommitFiberRoot: (fiber: Object, commitPriority?: number) => void, handleCommitFiberUnmount: (fiber: Object) => void, inspectElement: ( + requestID: number, id: number, - path?: Array, + inspectedPaths: Object, + forceUpdate: boolean, ) => InspectedElementPayload, logElementToConsole: (id: number) => void, overrideSuspense: (id: number, forceFallback: boolean) => void, diff --git a/packages/react-devtools-shared/src/backendAPI.js b/packages/react-devtools-shared/src/backendAPI.js new file mode 100644 index 0000000000000..372fd247d3b5a --- /dev/null +++ b/packages/react-devtools-shared/src/backendAPI.js @@ -0,0 +1,283 @@ +/** + * 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 {hydrate, fillInPath} from 'react-devtools-shared/src/hydration'; +import {separateDisplayNameAndHOCs} from 'react-devtools-shared/src/utils'; +import Store from 'react-devtools-shared/src/devtools/store'; + +import type { + InspectedElement as InspectedElementBackend, + InspectedElementPayload, +} from 'react-devtools-shared/src/backend/types'; +import type { + BackendEvents, + FrontendBridge, +} from 'react-devtools-shared/src/bridge'; +import type { + DehydratedData, + InspectedElement as InspectedElementFrontend, +} from 'react-devtools-shared/src/devtools/views/Components/types'; + +export function clearErrorsAndWarnings({ + bridge, + store, +}: {| + bridge: FrontendBridge, + store: Store, +|}): void { + store.rootIDToRendererID.forEach(rendererID => { + bridge.send('clearErrorsAndWarnings', {rendererID}); + }); +} + +export function clearErrorsForElement({ + bridge, + id, + rendererID, +}: {| + bridge: FrontendBridge, + id: number, + rendererID: number, +|}): void { + bridge.send('clearErrorsForFiberID', { + rendererID, + id, + }); +} + +export function clearWarningsForElement({ + bridge, + id, + rendererID, +}: {| + bridge: FrontendBridge, + id: number, + rendererID: number, +|}): void { + bridge.send('clearWarningsForFiberID', { + rendererID, + id, + }); +} + +export function copyInspectedElementPath({ + bridge, + id, + path, + rendererID, +}: {| + bridge: FrontendBridge, + id: number, + path: Array, + rendererID: number, +|}): void { + bridge.send('copyElementPath', { + id, + path, + rendererID, + }); +} + +export function inspectElement({ + bridge, + forceUpdate, + id, + inspectedPaths, + rendererID, +}: {| + bridge: FrontendBridge, + forceUpdate: boolean, + id: number, + inspectedPaths: Object, + rendererID: number, +|}): Promise { + const requestID = requestCounter++; + const promise = getPromiseForRequestID( + requestID, + 'inspectedElement', + bridge, + ); + + bridge.send('inspectElement', { + forceUpdate, + id, + inspectedPaths, + rendererID, + requestID, + }); + + return promise; +} + +let storeAsGlobalCount = 0; + +export function storeAsGlobal({ + bridge, + id, + path, + rendererID, +}: {| + bridge: FrontendBridge, + id: number, + path: Array, + rendererID: number, +|}): void { + bridge.send('storeAsGlobal', { + count: storeAsGlobalCount++, + id, + path, + rendererID, + }); +} + +const TIMEOUT_DELAY = 5000; + +let requestCounter = 0; + +function getPromiseForRequestID( + requestID: number, + eventType: $Keys, + bridge: FrontendBridge, +): Promise { + return new Promise((resolve, reject) => { + const cleanup = () => { + bridge.removeListener(eventType, onInspectedElement); + + clearTimeout(timeoutID); + }; + + const onInspectedElement = (data: any) => { + if (data.responseID === requestID) { + cleanup(); + resolve((data: T)); + } + }; + + const onTimeout = () => { + cleanup(); + reject(); + }; + + bridge.addListener(eventType, onInspectedElement); + + const timeoutID = setTimeout(onTimeout, TIMEOUT_DELAY); + }); +} + +export function cloneInspectedElementWithPath( + inspectedElement: InspectedElementFrontend, + path: Array, + value: Object, +): InspectedElementFrontend { + const hydratedValue = hydrateHelper(value, path); + const clonedInspectedElement = {...inspectedElement}; + + fillInPath(clonedInspectedElement, value, path, hydratedValue); + + return clonedInspectedElement; +} + +export function convertInspectedElementBackendToFrontend( + inspectedElementBackend: InspectedElementBackend, +): InspectedElementFrontend { + const { + canEditFunctionProps, + canEditFunctionPropsDeletePaths, + canEditFunctionPropsRenamePaths, + canEditHooks, + canEditHooksAndDeletePaths, + canEditHooksAndRenamePaths, + canToggleSuspense, + canViewSource, + hasLegacyContext, + id, + source, + type, + owners, + context, + hooks, + props, + rendererPackageName, + rendererVersion, + rootType, + state, + key, + errors, + warnings, + } = inspectedElementBackend; + + const inspectedElement: InspectedElementFrontend = { + canEditFunctionProps, + canEditFunctionPropsDeletePaths, + canEditFunctionPropsRenamePaths, + canEditHooks, + canEditHooksAndDeletePaths, + canEditHooksAndRenamePaths, + canToggleSuspense, + canViewSource, + hasLegacyContext, + id, + key, + rendererPackageName, + rendererVersion, + rootType, + source, + type, + owners: + owners === null + ? null + : owners.map(owner => { + const [displayName, hocDisplayNames] = separateDisplayNameAndHOCs( + owner.displayName, + owner.type, + ); + return { + ...owner, + displayName, + hocDisplayNames, + }; + }), + context: hydrateHelper(context), + hooks: hydrateHelper(hooks), + props: hydrateHelper(props), + state: hydrateHelper(state), + errors, + warnings, + }; + + return inspectedElement; +} + +function hydrateHelper( + dehydratedData: DehydratedData | null, + path?: Array, +): Object | null { + if (dehydratedData !== null) { + const {cleaned, data, unserializable} = dehydratedData; + + if (path) { + const {length} = path; + if (length > 0) { + // Hydration helper requires full paths, but inspection dehydrates with relative paths. + // In that event it's important that we adjust the "cleaned" paths to match. + return hydrate( + data, + cleaned.map(cleanedPath => cleanedPath.slice(length)), + unserializable.map(unserializablePath => + unserializablePath.slice(length), + ), + ); + } + } + + return hydrate(data, cleaned, unserializable); + } else { + return null; + } +} diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index 1da4d8eb3e549..dad25e93258c1 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -89,7 +89,9 @@ type ViewAttributeSourceParams = {| type InspectElementParams = {| ...ElementAndRendererID, - path?: Array, + forceUpdate: boolean, + inspectedPaths: Object, + requestID: number, |}; type StoreAsGlobalParams = {| @@ -117,7 +119,7 @@ type UpdateConsolePatchSettingsParams = {| showInlineWarningsAndErrors: boolean, |}; -type BackendEvents = {| +export type BackendEvents = {| extensionBackendInitialized: [], inspectedElement: [InspectedElementPayload], isBackendStorageAPISupported: [boolean], diff --git a/packages/react-devtools-shared/src/devtools/cache.js b/packages/react-devtools-shared/src/devtools/cache.js index af6a02faa0cf2..573f666402717 100644 --- a/packages/react-devtools-shared/src/devtools/cache.js +++ b/packages/react-devtools-shared/src/devtools/cache.js @@ -12,6 +12,8 @@ import type {Thenable} from 'shared/ReactTypes'; import * as React from 'react'; import {createContext} from 'react'; +// TODO (cache) Remove this cache; it is outdated and will not work with newer APIs like startTransition. + // Cache implementation was forked from the React repo: // https://github.com/facebook/react/blob/master/packages/react-cache/src/ReactCache.js // diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 6b1b83e524838..e5a6ac15fbde1 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -101,7 +101,7 @@ export default class Store extends EventEmitter<{| // Map of ID to (mutable) Element. // Elements are mutated to avoid excessive cloning during tree updates. - // The InspectedElementContext also relies on this mutability for its WeakMap usage. + // The InspectedElement Suspense cache also relies on this mutability for its WeakMap usage. _idToElement: Map = new Map(); // Should the React Native style editor panel be shown? @@ -378,42 +378,6 @@ export default class Store extends EventEmitter<{| 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; } diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Components.js b/packages/react-devtools-shared/src/devtools/views/Components/Components.js index 91e31d4bf0294..712afdd987287 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Components.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Components.js @@ -17,7 +17,6 @@ import { useRef, } from 'react'; import Tree from './Tree'; -import {InspectedElementContextController} from './InspectedElementContext'; import {OwnersListContextController} from './OwnersListContext'; import portaledContent from '../portaledContent'; import {SettingsModalContextController} from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContext'; @@ -25,7 +24,9 @@ import { localStorageGetItem, localStorageSetItem, } from 'react-devtools-shared/src/storage'; +import InspectedElementErrorBoundary from './InspectedElementErrorBoundary'; import InspectedElement from './InspectedElement'; +import {InspectedElementContextController} from './InspectedElementContext'; import {ModalDialog} from '../ModalDialog'; import SettingsModal from 'react-devtools-shared/src/devtools/views/Settings/SettingsModal'; import {NativeStyleContextController} from './NativeStyleEditor/context'; @@ -151,32 +152,34 @@ function Components(_: {||}) { return ( - -
- -
- -
-
-
-
-
- +
+ +
+ +
+
+
+
+
+ + }> - + + + - -
- - - -
- + + +
+ + + +
); diff --git a/packages/react-devtools-shared/src/devtools/views/Components/ExpandCollapseToggle.js b/packages/react-devtools-shared/src/devtools/views/Components/ExpandCollapseToggle.js index 8950696b7e4fc..1851c75e0391e 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/ExpandCollapseToggle.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/ExpandCollapseToggle.js @@ -14,17 +14,20 @@ import ButtonIcon from '../ButtonIcon'; import styles from './ExpandCollapseToggle.css'; type ExpandCollapseToggleProps = {| + disabled: boolean, isOpen: boolean, setIsOpen: Function, |}; export default function ExpandCollapseToggle({ + disabled, isOpen, setIsOpen, }: ExpandCollapseToggleProps) { return (
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 01b05a9fa33a1..48768b57aaac1 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js @@ -10,6 +10,8 @@ import * as React from 'react'; import { createContext, + unstable_startTransition as startTransition, + unstable_useCacheRefresh as useCacheRefresh, useCallback, useContext, useEffect, @@ -17,360 +19,134 @@ import { useRef, useState, } from 'react'; -import {unstable_batchedUpdates as batchedUpdates} from 'react-dom'; -import {createResource} from '../../cache'; -import {BridgeContext, StoreContext} from '../context'; -import {hydrate, fillInPath} from 'react-devtools-shared/src/hydration'; import {TreeStateContext} from './TreeContext'; -import {separateDisplayNameAndHOCs} from 'react-devtools-shared/src/utils'; +import {BridgeContext, StoreContext} from '../context'; +import { + checkForUpdate, + inspectElement, +} from 'react-devtools-shared/src/inspectedElementCache'; +import type {ReactNodeList} from 'shared/ReactTypes'; import type { - InspectedElement as InspectedElementBackend, - InspectedElementPayload, -} from 'react-devtools-shared/src/backend/types'; -import type { - DehydratedData, Element, - InspectedElement as InspectedElementFrontend, + InspectedElement, } from 'react-devtools-shared/src/devtools/views/Components/types'; -import type {Resource, Thenable} from '../../cache'; - -export type StoreAsGlobal = (id: number, path: Array) => void; -export type CopyInspectedElementPath = ( - id: number, - path: Array, -) => void; +type Path = Array; +type InspectPathFunction = (path: Path) => void; -export type GetInspectedElementPath = ( - id: number, - path: Array, -) => void; - -export type GetInspectedElement = ( - id: number, -) => InspectedElementFrontend | null; - -type RefreshInspectedElement = () => void; - -export type InspectedElementContextType = {| - copyInspectedElementPath: CopyInspectedElementPath, - getInspectedElementPath: GetInspectedElementPath, - getInspectedElement: GetInspectedElement, - refreshInspectedElement: RefreshInspectedElement, - storeAsGlobal: StoreAsGlobal, +type Context = {| + inspectedElement: InspectedElement | null, + inspectPaths: InspectPathFunction, |}; -const InspectedElementContext = createContext( - ((null: any): InspectedElementContextType), +export const InspectedElementContext = createContext( + ((null: any): Context), ); -InspectedElementContext.displayName = 'InspectedElementContext'; - -type ResolveFn = (inspectedElement: InspectedElementFrontend) => void; -type InProgressRequest = {| - promise: Thenable, - resolveFn: ResolveFn, -|}; - -const inProgressRequests: WeakMap = new WeakMap(); -const resource: Resource< - Element, - Element, - InspectedElementFrontend, -> = createResource( - (element: Element) => { - const request = inProgressRequests.get(element); - if (request != null) { - return request.promise; - } - - let resolveFn = ((null: any): ResolveFn); - const promise = new Promise(resolve => { - resolveFn = resolve; - }); - - inProgressRequests.set(element, {promise, resolveFn}); - return promise; - }, - (element: Element) => element, - {useWeakMap: true}, -); +const POLL_INTERVAL = 1000; -type Props = {| - children: React$Node, +export type Props = {| + children: ReactNodeList, |}; -function InspectedElementContextController({children}: Props) { +export function InspectedElementContextController({children}: Props) { + const {selectedElementID} = useContext(TreeStateContext); const bridge = useContext(BridgeContext); const store = useContext(StoreContext); - const storeAsGlobalCount = useRef(1); - - // Ask the backend to store the value at the specified path as a global variable. - const storeAsGlobal = useCallback( - (id: number, path: Array) => { - const rendererID = store.getRendererIDForElement(id); - if (rendererID !== null) { - bridge.send('storeAsGlobal', { - count: storeAsGlobalCount.current++, - id, - path, - rendererID, - }); - } - }, - [bridge, store], - ); - - // Ask the backend to copy the specified path to the clipboard. - const copyInspectedElementPath = useCallback( - (id: number, path: Array) => { - const rendererID = store.getRendererIDForElement(id); - if (rendererID !== null) { - bridge.send('copyElementPath', {id, path, rendererID}); - } - }, - [bridge, store], - ); - - // Ask the backend to fill in a "dehydrated" path; this will result in a "inspectedElement". - const getInspectedElementPath = useCallback( - (id: number, path: Array) => { - const rendererID = store.getRendererIDForElement(id); - if (rendererID !== null) { - bridge.send('inspectElement', {id, path, rendererID}); - } - }, - [bridge, store], - ); - - const getInspectedElement = useCallback( - (id: number) => { - const element = store.getElementByID(id); - if (element !== null) { - return resource.read(element); - } else { - return null; - } - }, - [store], - ); - - // It's very important that this context consumes selectedElementID and not inspectedElementID. - // Otherwise the effect that sends the "inspect" message across the bridge- - // 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, - ] = useState(null); - - // This effect handler invalidates the suspense cache and schedules rendering updates with React. - useEffect(() => { - const onInspectedElement = (data: InspectedElementPayload) => { - const {id} = data; + const refresh = useCacheRefresh(); - let element; + // Track when insepected paths have changed; we need to force the backend to send an udpate then. + const forceUpdateRef = useRef(true); - switch (data.type) { - case 'no-change': - case 'not-found': - // No-op - break; - case 'hydrated-path': - // Merge new data into previous object and invalidate cache - element = store.getElementByID(id); - if (element !== null) { - if (currentlyInspectedElement != null) { - const value = hydrateHelper(data.value, data.path); - const inspectedElement = {...currentlyInspectedElement}; + // Track the paths insepected for the currently selected element. + const [state, setState] = useState<{| + element: Element | null, + inspectedPaths: Object, + |}>({ + element: null, + inspectedPaths: {}, + }); - fillInPath(inspectedElement, data.value, data.path, value); + const element = + selectedElementID !== null ? store.getElementByID(selectedElementID) : null; - resource.write(element, inspectedElement); + const elementHasChanged = element !== null && element !== state.element; - // Schedule update with React if the currently-selected element has been invalidated. - if (id === selectedElementID) { - setCurrentlyInspectedElement(inspectedElement); - } - } - } - break; - case 'full-data': - const { - canEditFunctionProps, - canEditFunctionPropsDeletePaths, - canEditFunctionPropsRenamePaths, - canEditHooks, - canEditHooksAndDeletePaths, - canEditHooksAndRenamePaths, - canToggleSuspense, - canViewSource, - hasLegacyContext, - source, - type, - owners, - context, - hooks, - props, - rendererPackageName, - rendererVersion, - rootType, - state, - key, - errors, - warnings, - } = ((data.value: any): InspectedElementBackend); - - const inspectedElement: InspectedElementFrontend = { - canEditFunctionProps, - canEditFunctionPropsDeletePaths, - canEditFunctionPropsRenamePaths, - canEditHooks, - canEditHooksAndDeletePaths, - canEditHooksAndRenamePaths, - canToggleSuspense, - canViewSource, - hasLegacyContext, - id, - key, - rendererPackageName, - rendererVersion, - rootType, - source, - type, - owners: - owners === null - ? null - : owners.map(owner => { - const [ - displayName, - hocDisplayNames, - ] = separateDisplayNameAndHOCs( - owner.displayName, - owner.type, - ); - return { - ...owner, - displayName, - hocDisplayNames, - }; - }), - context: hydrateHelper(context), - hooks: hydrateHelper(hooks), - props: hydrateHelper(props), - state: hydrateHelper(state), - errors, - warnings, - }; - - element = store.getElementByID(id); - if (element !== null) { - const request = inProgressRequests.get(element); - if (request != null) { - inProgressRequests.delete(element); - batchedUpdates(() => { - request.resolveFn(inspectedElement); - setCurrentlyInspectedElement(inspectedElement); - }); - } else { - resource.write(element, inspectedElement); + // Reset the cached inspected paths when a new element is selected. + if (elementHasChanged) { + setState({ + element, + inspectedPaths: {}, + }); + } - // Schedule update with React if the currently-selected element has been invalidated. - if (id === selectedElementID) { - setCurrentlyInspectedElement(inspectedElement); - } + // Don't load a stale element from the backend; it wastes bridge bandwidth. + const inspectedElement = + !elementHasChanged && element !== null + ? inspectElement( + element, + state.inspectedPaths, + forceUpdateRef.current, + store, + bridge, + ) + : null; + + const inspectPaths: InspectPathFunction = useCallback( + (path: Path) => { + startTransition(() => { + forceUpdateRef.current = true; + setState(prevState => { + const cloned = {...prevState}; + let current = cloned.inspectedPaths; + path.forEach(key => { + if (!current[key]) { + current[key] = {}; } - } - break; - default: - break; - } - }; - - bridge.addListener('inspectedElement', onInspectedElement); - return () => bridge.removeListener('inspectedElement', onInspectedElement); - }, [bridge, currentlyInspectedElement, selectedElementID, store]); + current = current[key]; + }); + return cloned; + }); + refresh(); + }); + }, + [setState], + ); - // This effect handler polls for updates on the currently selected element. + // Force backend update when inspected paths change. useEffect(() => { - if (selectedElementID === null) { - return () => {}; - } - - const rendererID = store.getRendererIDForElement(selectedElementID); - - let timeoutID: TimeoutID | null = null; + forceUpdateRef.current = false; + }, [element, state]); - const sendRequest = () => { - timeoutID = null; - - if (rendererID !== null) { - bridge.send('inspectElement', {id: selectedElementID, rendererID}); - } - }; - - // Send the initial inspection request. - // We'll poll for an update in the response handler below. - sendRequest(); - - const onInspectedElement = (data: InspectedElementPayload) => { - // If this is the element we requested, wait a little bit and then ask for another update. - if (data.id === selectedElementID) { - switch (data.type) { - case 'no-change': - case 'full-data': - case 'hydrated-path': - if (timeoutID !== null) { - clearTimeout(timeoutID); - } - timeoutID = setTimeout(sendRequest, 1000); - break; - default: - break; - } - } - }; - - bridge.addListener('inspectedElement', onInspectedElement); - - return () => { - bridge.removeListener('inspectedElement', onInspectedElement); - - if (timeoutID !== null) { + // Periodically poll the selected element for updates. + useEffect(() => { + if (element !== null) { + const inspectedPaths = state.inspectedPaths; + const checkForUpdateWrapper = () => { + checkForUpdate({bridge, element, inspectedPaths, refresh, store}); + timeoutID = setTimeout(checkForUpdateWrapper, POLL_INTERVAL); + }; + let timeoutID = setTimeout(checkForUpdateWrapper, POLL_INTERVAL); + return () => { clearTimeout(timeoutID); - } - }; - }, [bridge, selectedElementID, store]); - - const value = useMemo( + }; + } + }, [ + element, + // Reset this timer any time the element we're inspecting gets a new response. + // No sense to ping right away after e.g. inspecting/hydrating a path. + inspectedElement, + state, + ]); + + const value = useMemo( () => ({ - copyInspectedElementPath, - getInspectedElement, - getInspectedElementPath, - refreshInspectedElement, - storeAsGlobal, + inspectedElement, + inspectPaths, }), - // InspectedElement is used to invalidate the cache and schedule an update with React. - [ - copyInspectedElementPath, - currentlyInspectedElement, - getInspectedElement, - getInspectedElementPath, - refreshInspectedElement, - storeAsGlobal, - ], + [inspectedElement, inspectPaths], ); return ( @@ -379,33 +155,3 @@ function InspectedElementContextController({children}: Props) { ); } - -function hydrateHelper( - dehydratedData: DehydratedData | null, - path?: Array, -): Object | null { - if (dehydratedData !== null) { - const {cleaned, data, unserializable} = dehydratedData; - - if (path) { - const {length} = path; - if (length > 0) { - // Hydration helper requires full paths, but inspection dehydrates with relative paths. - // In that event it's important that we adjust the "cleaned" paths to match. - return hydrate( - data, - cleaned.map(cleanedPath => cleanedPath.slice(length)), - unserializable.map(unserializablePath => - unserializablePath.slice(length), - ), - ); - } - } - - return hydrate(data, cleaned, unserializable); - } else { - return null; - } -} - -export {InspectedElementContext, InspectedElementContextController}; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContextTree.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContextTree.js index 22347cef07f63..8dfe6310a3609 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContextTree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContextTree.js @@ -20,20 +20,20 @@ import { ElementTypeFunction, } from 'react-devtools-shared/src/types'; -import type {GetInspectedElementPath} from './InspectedElementContext'; import type {InspectedElement} from './types'; import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; +import type {Element} from 'react-devtools-shared/src/devtools/views/Components/types'; type Props = {| bridge: FrontendBridge, - getInspectedElementPath: GetInspectedElementPath, + element: Element, inspectedElement: InspectedElement, store: Store, |}; export default function InspectedElementContextTree({ bridge, - getInspectedElementPath, + element, inspectedElement, store, }: Props) { @@ -81,15 +81,15 @@ export default function InspectedElementContextTree({ canEditValues={!isReadOnly} canRenamePaths={!isReadOnly} canRenamePathsAtDepth={canRenamePathsAtDepth} - type="context" depth={1} - getInspectedElementPath={getInspectedElementPath} + element={element} hidden={false} inspectedElement={inspectedElement} name={name} path={[name]} pathRoot="context" store={store} + type="context" value={value} /> ))} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementErrorBoundary.css b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementErrorBoundary.css new file mode 100644 index 0000000000000..399f83cec45b0 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementErrorBoundary.css @@ -0,0 +1,21 @@ +.Error { + justify-content: center; + align-items: center; + display: flex; + flex-direction: column; + height: 100%; + font-size: var(--font-size-sans-large); + font-weight: bold; + text-align: center; + background-color: var(--color-error-background); + color: var(--color-error-text); + border: 1px solid var(--color-error-border); + padding: 1rem; +} + +.Message { + margin-bottom: 1rem; +} + +.RetryButton { +} \ No newline at end of file diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementErrorBoundary.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementErrorBoundary.js new file mode 100644 index 0000000000000..09c11664dcf77 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementErrorBoundary.js @@ -0,0 +1,93 @@ +/** + * 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 {Component, useContext} from 'react'; +import {TreeDispatcherContext} from './TreeContext'; +import Button from 'react-devtools-shared/src/devtools/views/Button'; +import styles from './InspectedElementErrorBoundary.css'; + +import type {DispatcherContext} from './InspectedElementErrorBoundary.css'; + +type WrapperProps = {| + children: React$Node, +|}; + +export default function InspectedElementErrorBoundaryWrapper({ + children, +}: WrapperProps) { + const dispatch = useContext(TreeDispatcherContext); + + return ( + + ); +} + +type Props = {| + children: React$Node, + dispatch: DispatcherContext, +|}; + +type State = {| + errorMessage: string | null, + hasError: boolean, +|}; + +const InitialState: State = { + errorMessage: null, + hasError: false, +}; + +class InspectedElementErrorBoundary extends Component { + state: State = InitialState; + + static getDerivedStateFromError(error: any) { + const errorMessage = + typeof error === 'object' && + error !== null && + error.hasOwnProperty('message') + ? error.message + : error; + + return { + errorMessage, + hasError: true, + }; + } + + render() { + const {children} = this.props; + const {errorMessage, hasError} = this.state; + + if (hasError) { + return ( +
+
{errorMessage || 'Error'}
+ +
+ ); + } + + return children; + } + + _retry = () => { + const {dispatch} = this.props; + dispatch({ + type: 'SELECT_ELEMENT_BY_ID', + payload: null, + }); + this.setState({ + errorMessage: null, + hasError: false, + }); + }; +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementErrorsAndWarningsTree.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementErrorsAndWarningsTree.js index 1f161c98836b6..3b7aaca3216d5 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementErrorsAndWarningsTree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementErrorsAndWarningsTree.js @@ -8,14 +8,21 @@ */ import * as React from 'react'; -import {useContext} from 'react'; +import { + useContext, + unstable_useCacheRefresh as useCacheRefresh, + unstable_useTransition as useTransition, +} 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 { + clearErrorsForElement as clearErrorsForElementAPI, + clearWarningsForElement as clearWarningsForElementAPI, +} from 'react-devtools-shared/src/backendAPI'; import type {InspectedElement} from './types'; import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; @@ -31,7 +38,45 @@ export default function InspectedElementErrorsAndWarningsTree({ inspectedElement, store, }: Props) { - const {refreshInspectedElement} = useContext(InspectedElementContext); + const refresh = useCacheRefresh(); + + const [ + startClearErrorsTransition, + isErrorsTransitionPending, + ] = useTransition(); + const clearErrorsForInspectedElement = () => { + const {id} = inspectedElement; + const rendererID = store.getRendererIDForElement(id); + if (rendererID !== null) { + startClearErrorsTransition(() => { + clearErrorsForElementAPI({ + bridge, + id, + rendererID, + }); + refresh(); + }); + } + }; + + const [ + startClearWarningsTransition, + isWarningsTransitionPending, + ] = useTransition(); + const clearWarningsForInspectedElement = () => { + const {id} = inspectedElement; + const rendererID = store.getRendererIDForElement(id); + if (rendererID !== null) { + startClearWarningsTransition(() => { + clearWarningsForElementAPI({ + bridge, + id, + rendererID, + }); + refresh(); + }); + } + }; const {showInlineWarningsAndErrors} = useContext(SettingsContext); if (!showInlineWarningsAndErrors) { @@ -40,26 +85,6 @@ export default function InspectedElementErrorsAndWarningsTree({ 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 && ( @@ -67,8 +92,9 @@ export default function InspectedElementErrorsAndWarningsTree({ badgeClassName={styles.ErrorBadge} bridge={bridge} className={styles.ErrorTree} - clearMessages={clearErrors} + clearMessages={clearErrorsForInspectedElement} entries={errors} + isTransitionPending={isErrorsTransitionPending} label="errors" messageClassName={styles.Error} /> @@ -78,8 +104,9 @@ export default function InspectedElementErrorsAndWarningsTree({ badgeClassName={styles.WarningBadge} bridge={bridge} className={styles.WarningTree} - clearMessages={clearWarnings} + clearMessages={clearWarningsForInspectedElement} entries={warnings} + isTransitionPending={isWarningsTransitionPending} label="warnings" messageClassName={styles.Warning} /> @@ -94,6 +121,7 @@ type TreeProps = {| className: string, clearMessages: () => {}, entries: Array<[string, number]>, + isTransitionPending: boolean, label: string, messageClassName: string, |}; @@ -104,6 +132,7 @@ function Tree({ className, clearMessages, entries, + isTransitionPending, label, messageClassName, }: TreeProps) { @@ -115,6 +144,7 @@ function Tree({
{label}
diff --git a/packages/react-devtools-shared/src/devtools/views/ErrorBoundary.js b/packages/react-devtools-shared/src/devtools/views/ErrorBoundary.js index bd932cff395f9..7b777115d3b5e 100644 --- a/packages/react-devtools-shared/src/devtools/views/ErrorBoundary.js +++ b/packages/react-devtools-shared/src/devtools/views/ErrorBoundary.js @@ -38,14 +38,25 @@ const InitialState: State = { export default class ErrorBoundary extends Component { state: State = InitialState; - componentDidCatch(error: any, {componentStack}: any) { + static getDerivedStateFromError(error: any) { const errorMessage = - typeof error === 'object' && error.hasOwnProperty('message') + typeof error === 'object' && + error !== null && + error.hasOwnProperty('message') ? error.message : error; + return { + errorMessage, + hasError: true, + }; + } + + componentDidCatch(error: any, {componentStack}: any) { const callStack = - typeof error === 'object' && error.hasOwnProperty('stack') + typeof error === 'object' && + error !== null && + error.hasOwnProperty('stack') ? error.stack .split('\n') .slice(1) @@ -55,8 +66,6 @@ export default class ErrorBoundary extends Component { this.setState({ callStack, componentStack, - errorMessage, - hasError: true, }); } diff --git a/packages/react-devtools-shared/src/inspectedElementCache.js b/packages/react-devtools-shared/src/inspectedElementCache.js new file mode 100644 index 0000000000000..6169207df0dbe --- /dev/null +++ b/packages/react-devtools-shared/src/inspectedElementCache.js @@ -0,0 +1,220 @@ +/** + * 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 { + unstable_getCacheForType, + unstable_startTransition as startTransition, +} from 'react'; +import Store from './devtools/store'; +import { + convertInspectedElementBackendToFrontend, + inspectElement as inspectElementAPI, +} from './backendAPI'; + +import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; +import type {Wakeable} from 'shared/ReactTypes'; +import type { + InspectedElement as InspectedElementBackend, + InspectedElementPayload, +} from 'react-devtools-shared/src/backend/types'; +import type { + Element, + InspectedElement as InspectedElementFrontend, +} from 'react-devtools-shared/src/devtools/views/Components/types'; + +const Pending = 0; +const Resolved = 1; +const Rejected = 2; + +type PendingRecord = {| + status: 0, + value: Wakeable, +|}; + +type ResolvedRecord = {| + status: 1, + value: T, +|}; + +type RejectedRecord = {| + status: 2, + value: string, +|}; + +type Record = PendingRecord | ResolvedRecord | RejectedRecord; + +function readRecord(record: Record): ResolvedRecord { + if (record.status === Resolved) { + // This is just a type refinement. + return record; + } else { + throw record.value; + } +} + +type InspectedElementMap = WeakMap>; +type CacheSeedKey = () => InspectedElementMap; + +function createMap(): InspectedElementMap { + return new WeakMap(); +} + +function getRecordMap(): WeakMap> { + return unstable_getCacheForType(createMap); +} + +function createCacheSeed( + element: Element, + inspectedElement: InspectedElementFrontend, +): [CacheSeedKey, InspectedElementMap] { + const newRecord: Record = { + status: Resolved, + value: inspectedElement, + }; + const map = createMap(); + map.set(element, newRecord); + return [createMap, map]; +} + +/** + * Fetches element props and state from the backend for inspection. + * This method should be called during render; it will suspend if data has not yet been fetched. + */ +export function inspectElement( + element: Element, + inspectedPaths: Object, + forceUpdate: boolean, + store: Store, + bridge: FrontendBridge, +): InspectedElementFrontend | null { + const map = getRecordMap(); + let record = map.get(element); + if (!record) { + const callbacks = new Set(); + const wakeable: Wakeable = { + then(callback) { + callbacks.add(callback); + }, + }; + const wake = () => { + // This assumes they won't throw. + callbacks.forEach(callback => callback()); + callbacks.clear(); + }; + const newRecord: Record = (record = { + status: Pending, + value: wakeable, + }); + + const rendererID = store.getRendererIDForElement(element.id); + if (rendererID == null) { + const rejectedRecord = ((newRecord: any): RejectedRecord); + rejectedRecord.status = Rejected; + rejectedRecord.value = 'Inspected element not found.'; + return null; + } + + inspectElementAPI({ + bridge, + forceUpdate: true, + id: element.id, + inspectedPaths, + rendererID: ((rendererID: any): number), + }).then( + (data: InspectedElementPayload) => { + if (newRecord.status === Pending) { + switch (data.type) { + case 'no-change': + // This response type should never be received. + // We always send forceUpdate:true when we have a cache miss. + break; + + case 'not-found': + const notFoundRecord = ((newRecord: any): RejectedRecord); + notFoundRecord.status = Rejected; + notFoundRecord.value = 'Inspected element not found.'; + wake(); + break; + + case 'full-data': + const resolvedRecord = ((newRecord: any): ResolvedRecord); + resolvedRecord.status = Resolved; + resolvedRecord.value = convertInspectedElementBackendToFrontend( + ((data.value: any): InspectedElementBackend), + ); + wake(); + break; + } + } + }, + + () => { + // Timed out without receiving a response. + if (newRecord.status === Pending) { + const timedOutRecord = ((newRecord: any): RejectedRecord); + timedOutRecord.status = Rejected; + timedOutRecord.value = 'Inspected element timed out.'; + wake(); + } + }, + ); + map.set(element, record); + } + + const response = readRecord(record).value; + return response; +} + +type RefreshFunction = ( + seedKey: CacheSeedKey, + cacheMap: InspectedElementMap, +) => void; + +/** + * Asks the backend for updated props and state from an expected element. + * This method should never be called during render; call it from an effect or event handler. + * This method will schedule an update if updated information is returned. + */ +export function checkForUpdate({ + bridge, + element, + inspectedPaths, + refresh, + store, +}: { + bridge: FrontendBridge, + element: Element, + inspectedPaths: Object, + refresh: RefreshFunction, + store: Store, +}): void { + const {id} = element; + const rendererID = store.getRendererIDForElement(id); + if (rendererID != null) { + inspectElementAPI({ + bridge, + forceUpdate: false, + id, + inspectedPaths, + rendererID, + }).then((data: InspectedElementPayload) => { + switch (data.type) { + case 'full-data': + const inspectedElement = convertInspectedElementBackendToFrontend( + ((data.value: any): InspectedElementBackend), + ); + startTransition(() => { + const [key, value] = createCacheSeed(element, inspectedElement); + refresh(key, value); + }); + break; + } + }); + } +} diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 16e488409e0c4..c6ffacb27bab2 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -23,7 +23,7 @@ export const enableSchedulerTracing = __PROFILE__; export const enableSuspenseServerRenderer = false; export const enableSelectiveHydration = false; export const enableLazyElements = false; -export const enableCache = false; +export const enableCache = __EXPERIMENTAL__; export const disableJavaScriptURLs = false; export const disableInputAttributeSyncing = false; export const enableSchedulerDebugging = false; diff --git a/scripts/jest/config.build-devtools.js b/scripts/jest/config.build-devtools.js index 0d5a6039e414d..761479ce45c50 100644 --- a/scripts/jest/config.build-devtools.js +++ b/scripts/jest/config.build-devtools.js @@ -58,6 +58,9 @@ module.exports = Object.assign({}, baseConfig, { transformIgnorePatterns: ['/node_modules/', '/build2/'], testRegex: 'packages/react-devtools-shared/src/__tests__/[^]+.test.js$', snapshotSerializers: [ + require.resolve( + '../../packages/react-devtools-shared/src/__tests__/dehydratedValueSerializer.js' + ), require.resolve( '../../packages/react-devtools-shared/src/__tests__/inspectedElementSerializer.js' ), diff --git a/scripts/jest/preprocessor.js b/scripts/jest/preprocessor.js index f57005c9407fa..072071be07fbd 100644 --- a/scripts/jest/preprocessor.js +++ b/scripts/jest/preprocessor.js @@ -59,6 +59,10 @@ const babelOptions = { module.exports = { process: function(src, filePath) { + if (filePath.match(/\.css$/)) { + // Don't try to parse CSS modules; they aren't needed for tests anyway. + return ''; + } if (filePath.match(/\.coffee$/)) { return coffee.compile(src, {bare: true}); }