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}); }