diff --git a/extension/.circleci/config.yml b/extension/.circleci/config.yml new file mode 100644 index 0000000000000..ebec77b77e72d --- /dev/null +++ b/extension/.circleci/config.yml @@ -0,0 +1,58 @@ +# Javascript Node CircleCI 2.0 configuration file +# +# Check https://circleci.com/docs/2.0/language-javascript/ for more details +# +version: 2 +jobs: + build: + docker: + # specify the version you desire here + - image: circleci/node:10.12.0 + + # Specify service dependencies here if necessary + # CircleCI maintains a library of pre-built images + # documented at https://circleci.com/docs/2.0/circleci-images/ + # - image: circleci/mongo:3.4.4 + + working_directory: ~/repo + + steps: + - checkout + + # Download and cache dependencies + - restore_cache: + name: Restore node_modules cache + keys: + - v1-node-{{ arch }}-{{ .Branch }}-{{ checksum "yarn.lock" }} + - v1-node-{{ arch }}-{{ .Branch }}- + - v1-node-{{ arch }}- + + - run: + name: Nodejs Version + command: node --version + + - run: + name: Install Packages + command: yarn install --frozen-lockfile + + - save_cache: + name: Save node_modules cache + key: v1-node-{{ arch }}-{{ .Branch }}-{{ checksum "yarn.lock" }} + paths: + - node_modules + + - run: + name: Check formatting + command: yarn prettier:ci + + - run: + name: Check lint + command: yarn lint:ci + + - run: + name: Check Flow + command: yarn flow + + - run: + name: Test Packages + command: yarn test \ No newline at end of file diff --git a/extension/.eslintignore b/extension/.eslintignore new file mode 100644 index 0000000000000..7f1c5b3bd4a87 --- /dev/null +++ b/extension/.eslintignore @@ -0,0 +1,13 @@ +node_modules + +shells/browser/chrome/build +shells/browser/firefox/build +shells/browser/shared/build +shells/dev/dist +packages/react-devtools-core/dist +packages/react-devtools-inline/dist +vendor +*.js.snap + +package-lock.json +yarn.lock diff --git a/extension/.eslintrc b/extension/.eslintrc new file mode 100644 index 0000000000000..5622bebb6f159 --- /dev/null +++ b/extension/.eslintrc @@ -0,0 +1,19 @@ +{ + "extends": ["react-app","plugin:prettier/recommended"], + "plugins": ["react-hooks"], + "rules": { + "jsx-a11y/anchor-has-content": "off", + "no-loop-func": "off", + "react-hooks/exhaustive-deps": "error", + "react-hooks/rules-of-hooks": "error" + }, + "settings": { + "version": "detect" + }, + "globals": { + "__DEV__": "readonly", + "__TEST__": "readonly", + "jasmine": "readonly", + "spyOn": "readonly" + } +} diff --git a/extension/.flowconfig b/extension/.flowconfig new file mode 100644 index 0000000000000..d613bab513a35 --- /dev/null +++ b/extension/.flowconfig @@ -0,0 +1,35 @@ +[ignore] +.*/react/node_modules/.* +.*/electron/node_modules/.* +.*node_modules/archiver-utils +.*node_modules/babel.* +.*node_modules/browserify-zlib/.* +.*node_modules/gh-pages/.* +.*node_modules/invariant/.* +.*node_modules/json-loader.* +.*node_modules/json5.* +.*node_modules/node-libs-browser.* +.*node_modules/webpack.* +.*node_modules/fbjs/flow.* +.*node_modules/web-ext.* +shells/browser/chrome/build/* +shells/browser/firefox/build/* +shells/dev/build/* + +[include] + +[libs] +/flow-typed/ +./flow.js + +[lints] + +[options] +server.max_workers=4 +esproposal.class_instance_fields=enable +suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe +suppress_comment=\\(.\\|\n\\)*\\$FlowIssue +suppress_comment=\\(.\\|\n\\)*\\$FlowIgnore +module.name_mapper='^src' ->'/src' + +[strict] diff --git a/extension/.gitignore b/extension/.gitignore new file mode 100644 index 0000000000000..8ec77912d297f --- /dev/null +++ b/extension/.gitignore @@ -0,0 +1,21 @@ +/shells/browser/chrome/*.crx +/shells/browser/chrome/*.pem +/shells/browser/firefox/*.xpi +/shells/browser/firefox/*.pem +/shells/browser/shared/build +/packages/react-devtools-core/dist +/packages/react-devtools-inline/dist +/shells/dev/dist +build +/node_modules +/packages/react-devtools-core/node_modules +/packages/react-devtools-inline/node_modules +/packages/react-devtools/node_modules +npm-debug.log +yarn-error.log +.DS_Store +yarn-error.log +.vscode +.idea +.watchmanconfig +*.pem \ No newline at end of file diff --git a/extension/.prettierignore b/extension/.prettierignore new file mode 100644 index 0000000000000..640e359715bd2 --- /dev/null +++ b/extension/.prettierignore @@ -0,0 +1,9 @@ +node_modules + +shells/browser/chrome/build +shells/browser/firefox/build +shells/dev/build +vendor + +package-lock.json +yarn.lock \ No newline at end of file diff --git a/extension/.prettierrc b/extension/.prettierrc new file mode 100644 index 0000000000000..c1a6f66713166 --- /dev/null +++ b/extension/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "es5" +} diff --git a/extension/CHANGELOG.md b/extension/CHANGELOG.md new file mode 100644 index 0000000000000..8604ca7a83efc --- /dev/null +++ b/extension/CHANGELOG.md @@ -0,0 +1,142 @@ +# React DevTools changelog + + + +## 4.0.0 (release date TBD) + +### General changes + +#### Improved performance +The legacy DevTools extension used to add significant performance overhead, making it unusable for some larger React applications. That overhead has been effectively eliminated in version 4. + +[Learn more](https://github.com/bvaughn/react-devtools-experimental/blob/master/OVERVIEW.md) about the performance optimizations that made this possible. + +#### Component stacks + +React component authors have often requested a way to log warnings that include the React ["component stack"](https://reactjs.org/docs/error-boundaries.html#component-stack-traces). DevTools now provides an option to automatically append this information to warnings (`console.warn`) and errors (`console.error`). + +![Example console warning with component stack added](https://user-images.githubusercontent.com/29597/62228120-eec3da80-b371-11e9-81bb-018c1e577389.png) + +It can be disabled in the general settings panel: + +![Settings panel showing "component stacks" option](https://user-images.githubusercontent.com/29597/62227882-8f65ca80-b371-11e9-8a4e-5d27011ad1aa.png) + +### Components tree changes + +#### Component filters + +Large component trees can sometimes be hard to navigate. DevTools now provides a way to filter components so that you can hide ones you're not interested in seeing. + +![Component filter demo video](https://user-images.githubusercontent.com/29597/62229209-0bf9a880-b374-11e9-8f84-cebd6c1a016b.gif) + +Host nodes (e.g. HTML `
`, React Native `View`) are now hidden by default, but you can see them by disabling that filter. + +Filter preferences are remembered between sessions. + +#### No more in-line props + +Components in the tree no longer show in-line props. This was done to [make DevTools faster](https://github.com/bvaughn/react-devtools-experimental/blob/master/OVERVIEW.md) and to make it easier to browse larger component trees. + +You can view a component's props, state, and hooks by selecting it: + +![Inspecting props](https://user-images.githubusercontent.com/29597/62303001-37da6400-b430-11e9-87fd-10a94df88efa.png) + +#### "Rendered by" list + +In React, an element's "owner" refers the thing that rendered it. Sometimes an element's parent is also its owner, but usually they're different. This distinction is important because props come from owners. + +![Example code](https://user-images.githubusercontent.com/29597/62229551-bbcf1600-b374-11e9-8411-8ff411f4f847.png) + +When you are debugging an unexpected prop value, you can save time if you skip over the parents. + +DevTools v4 adds a new "rendered by" list in the right hand pane that allows you to quickly step through the list of owners to speed up your debugging. + +![Example video showing the "rendered by" list](https://user-images.githubusercontent.com/29597/62229747-4152c600-b375-11e9-9930-3f6b3b92be7a.gif) + +#### Owners tree + +The inverse of the "rendered by" list is called the "owners tree". It is the list of things rendered by a particular component- (the things it "owns"). This view is kind of like looking at the source of the component's render method, and can be a helpful way to explore large, unfamiliar React applications. + +Double click a component to view the owners tree and click the "x" button to return to the full component tree: + +![Demo showing "owners tree" feature](https://user-images.githubusercontent.com/29597/62229452-84f90000-b374-11e9-818a-61eec6be0bb4.gif) + +#### No more horizontal scrolling + +Deeply nested components used to require both vertical and horizontal scrolling to see, making it easy to "get lost" within large component trees. DevTools now dynamically adjusts nesting indentation to eliminate horizontal scrolling. + +![Video demonstration dynamic indentation to eliminate horizontal scrolling](https://user-images.githubusercontent.com/29597/62246661-f8ad0400-b398-11e9-885f-284f150a6d76.gif) + +#### Improved hooks support + +Hooks now have the same level of support as props and state: values can be edited, arrays and objects can be drilled into, etc. + +![Video demonstrating hooks support](https://user-images.githubusercontent.com/29597/62230532-d86c4d80-b376-11e9-8629-1b2129b210d6.gif) + +#### Improved search UX + +Legacy DevTools search filtered the components tree to show matching nodes as roots. This made the overall structure of the application harder to reason about, because it displayed ancestors as siblings. + +Search results are now shown inline similar to the browser's find-in-page search. + +![Video demonstrating the search UX](https://user-images.githubusercontent.com/29597/62230923-c63edf00-b377-11e9-9f95-aa62ddc8f62c.gif) + +#### Higher order components + +[Higher order components](https://reactjs.org/docs/higher-order-components.html) (or HOCs) often provide a [custom `displayName`](https://reactjs.org/docs/higher-order-components.html#convention-wrap-the-display-name-for-easy-debugging) following a convention of `withHOC(InnerComponentName)` in order to make it easier to identify components in React warnings and in DevTools. + +The new Components tree formats these HOC names (along with several built-in utilities like `React.memo` and `React.forwardRef`) as a special badge to the right of the decorated component name. + +![Screenshot showing HOC badges](https://user-images.githubusercontent.com/29597/62302774-c4385700-b42f-11e9-9ef4-49c5f18d6276.png) + +Components decorated with multiple HOCs show the topmost badge and a count. Selecting the component shows all of the HOCs badges in the properties panel. + +![Screenshot showing a component with multiple HOC badges](https://user-images.githubusercontent.com/29597/62303729-7fadbb00-b431-11e9-8685-45f5ab52b30b.png) + +#### Suspense toggle + +React's experimental [Suspense API](https://reactjs.org/docs/react-api.html#suspense) lets components "wait" for something before rendering. `` components can be used to specify loading states when components deeper in the tree are waiting to render. + +DevTools lets you test these loading states with a new toggle: + +![Video demonstrating suspense toggle UI](https://user-images.githubusercontent.com/29597/62231446-e15e1e80-b378-11e9-92d4-086751dc65fc.gif) + +### Profiler changes + +#### Reload and profile + +The profiler is a powerful tool for performance tuning React components. Legacy DevTools supported profiling, but only after it detected a profiling-capable version of React. Because of this there was no way to profile the initial _mount_ (one of the most performance sensitive parts) of an application. + +This feature is now supported with a "reload and profile" action: + +![Video demonstrating the reload-and-profile feature](https://user-images.githubusercontent.com/29597/62233455-7a8f3400-b37d-11e9-9563-ec334bfb2572.gif) + +#### Import/export + +Profiler data can now be exported and shared with other developers to enable easier collaboration. + +![Video demonstrating exporting and importing profiler data](https://user-images.githubusercontent.com/29597/62233911-6566d500-b37e-11e9-9052-692378c92538.gif) + +Exports include all commits, timings, interactions, etc. + +#### "Why did this render?" + +"Why did this render?" is a common question when profiling. The profiler now helps answer this question by recording which props and state change between renders. + +![Video demonstrating profiler "why did this render?" feature](https://user-images.githubusercontent.com/29597/62234698-0f932c80-b380-11e9-8cf3-a5183af0c388.gif) + +Because this feature adds a small amount of overhead, it can be disabled in the profiler settings panel. + +#### Component renders list + +The profiler now displays a list of each time the selected component rendered during a profiling session, along with the duration of each render. This list can be used to quickly jump between commits when analyzing the performance of a specific component. + +![Video demonstrating profiler's component renders list](https://user-images.githubusercontent.com/29597/62234547-bcb97500-b37f-11e9-9615-54fba8b574b9.gif) diff --git a/extension/OVERVIEW.md b/extension/OVERVIEW.md new file mode 100644 index 0000000000000..b7ec0639ca9e0 --- /dev/null +++ b/extension/OVERVIEW.md @@ -0,0 +1,270 @@ +# Overview + +The React DevTools extension consists of multiple pieces: +* The **frontend** portion is the extension you see (the Components tree, the Profiler, etc.). +* The **backend** portion is invisible. It runs in the same context as React itself. When React commits changes to e.g. the DOM, the backend is responsible for notifying the frontend by sending a message through the **bridge** (an abstraction around e.g. `postMessage`). + +One of the largest performance bottlenecks of the old React DevTools was the amount of bridge traffic. Each time React commits an update, the backend sends every fiber that changed across the bridge, resulting in a lot of (JSON) serialization. The primary goal for the DevTools rewrite was to reduce this traffic. Instead of sending everything across the bridge, **the backend should only send the minimum amount required to render the Components tree**. The frontend can request more information (e.g. an element's props) on demand, only as needed. + +The old DevTools also rendered the entire application tree in the form of a large DOM structure of nested nodes. A secondary goal of the rewrite was to avoid rendering unnecessary nodes by using a windowing library (specifically [react-window](https://github.com/bvaughn/react-window)). + +## Components panel + +### Serializing the tree + +Every React commit that changes the tree in a way DevTools cares about results in an "_operations_" message being sent across the bridge. These messages are lightweight patches that describe the changes that were made. (We don't resend the full tree structure like in legacy DevTools.) + +The payload for each message is a typed array. The first two entries are numbers that identify which renderer and root the update belongs to (for multi-root support). Then the strings are encoded in a [string table](#string-table). The rest of the array depends on the operations being made to the tree. + +No updates are required for most commits because we only send the following bits of information: element type, id, parent id, owner id, name, and key. Additional information (e.g. props, state) requires a separate ["_inspectElement_" message](#inspecting-an-element). + +#### String table + +The string table is encoded right after the first two numbers. + +It consists of: + +1. the total length of next items that belong to string table +2. for each string in a table: + 1. encoded size + 2. a list of its UTF encoded codepoints + +For example, for `Foo` and `Bar` we would see: + +``` +[ + 8, // string table length + 3, // encoded display name size + 70, // "F" + 111, // "o" + 111, // "o" + 3, // encoded display name size + 66, // "B" + 97, // "a" + 114, // "r" +] +``` + +Later operations will reference strings by a one-based index. For example, `1` would mean `"Foo"`, and `2` would mean `"Bar"`. The `0` string id always represents `null` and isn't explicitly encoded in the table. + +#### Adding a root node + +Adding a root to the tree requires sending 4 numbers: + +1. add operation constant (`1`) +1. fiber id +1. element type constant (`8 === ElementTypeRoot`) +1. profiling supported flag + +For example, adding a root fiber with an id of 1: +```js +[ + 1, // add operation + 1, // fiber id + 8, // ElementTypeRoot + 1, // this root's renderer supports profiling +] +``` + +#### Adding a leaf node + +Adding a leaf node takes a variable number of numbers since we need to decode the name (and potentially the key): + +1. add operation constant (`1`) +1. fiber id +1. element type constant (e.g. `1 === ElementTypeClass`) +1. parent fiber id +1. owner fiber id +1. string table id for `displayName` +1. string table id for `key` + +For example, adding a function component `` with an id 2: +```js +[ + 1, // add operation + 2, // fiber id + 1, // ElementTypeClass + 1, // parent id + 0, // owner id + 3, // encoded display name size + 1, // id of "Foo" displayName in the string table + 0, // id of null key in the string table (always zero for null) +] +``` + +#### Removing a node + +Removing a fiber from the tree (a root or a leaf) requires sending: + +1. remove operation constant (`2`) +1. how many items were removed +1. number of children + * (followed by a children-first list of removed fiber ids) + +For example, removing fibers with ids of 35 and 21: +```js +[ + 2, // remove operation + 2, // number of removed fibers + 35, // first removed id + 21, // second removed id +] +``` + +#### Re-ordering children + +1. re-order children constant (`3`) +1. fiber id +1. number of children + * (followed by an ordered list of child fiber ids) + +For example: +```js +[ + 3, // re-order operation + 15, // fiber id + 2, // number of children + 35, // first child id + 21, // second child id +] +``` + +#### Updating tree base duration + +While profiling is in progress, we send an extra operation any time a fiber is added or a updated in a way that affects its tree base duration. This information is needed by the Profiler UI in order to render the "snapshot" and "ranked" chart views. + +1. tree base duration constant (`4`) +1. fiber id +1. tree base duration + +For example, updating the base duration for a fiber with an id of 1: +```js +[ + 4, // tree base duration operation + 1, // fiber id + 32, // new tree base duration value +] +``` + +## Reconstructing the tree + +The frontend stores its information about the tree in a map of id to objects with the following keys: + +* id: `number` +* parentID: `number` +* children: `Array` +* type: `number` (constant) +* displayName: `string | null` +* key: `number | string | null` +* ownerID: `number` +* depth: `number` 1 +* weight: `number` 2 + +1 The `depth` value determines how much padding/indentation to use for the element when rendering it in the Components panel. (This preserves the appearance of a nested tree, even though the view is a flat list.) + +2 The `weight` of an element is the number of elements (including itself) below it in the tree. We cache this property so that we can quickly determine the total number of Components as well as to find the Nth element within that set. (This enables us to use windowing.) This value needs to be adjusted each time elements are added or removed from the tree, but we amortize this over time to avoid any big performance hits when rendering the tree. + +#### Finding the element at index N + +The tree data structure lets us impose an order on elements and "quickly" find the Nth one using the `weight` attribute. + +First we find which root contains the index: +```js +let rootID; +let root; +let rootWeight = 0; +for (let i = 0; i < this._roots.length; i++) { + rootID = this._roots[i]; + root = this._idToElement.get(rootID); + if (root.children.length === 0) { + continue; + } else if (rootWeight + root.weight > index) { + break; + } else { + rootWeight += root.weight; + } +} +``` + +We skip the root itself because don't display them in the tree: +```js +const firstChildID = root.children[0]; +``` + +Then we traverse the tree to find the element: +```js +let currentElement = this._idToElement.get(firstChildID); +let currentWeight = rootWeight; +while (index !== currentWeight) { + for (let i = 0; i < currentElement.children.length; i++) { + const childID = currentElement.children[i]; + const child = this._idToElement.get(childID); + const { weight } = child; + if (index <= currentWeight + weight) { + currentWeight++; + currentElement = child; + break; + } else { + currentWeight += weight; + } + } +} +``` + +## Inspecting an element + +When an element is mounted in the tree, DevTools sends a minimal amount of information about it across the bridge. This information includes its display name, type, and key- but does _not_ include things like props or state. (These values are often expensive to serialize and change frequently, which would add a significant amount of load to the bridge.) + +Instead DevTools lazily requests additional information about an element only when it is selected in the "Components" tab. At that point, the frontend requests this information by sending a special "_inspectElement_" message containing the id of the element being inspected. The backend then responds with an "_inspectedElement_" message containing the additional details. + +### Polling strategy + +Elements can update frequently, especially in response to things like scrolling events. Since props and state can be large, we avoid sending this information across the bridge every time the selected element is updated. Instead, the frontend polls the backend for updates about once a second. The backend tracks when the element was last "inspected" and sends a special no-op response if it has not re-rendered since then. + +### Deeply nested properties + +Even when dealing with a single component, serializing deeply nested properties can be expensive. Because of this, DevTools uses a technique referred to as "dehyration" to only send a shallow copy of the data on initial inspection. DevTools then fills in the missing data on demand as a user expands nested objects or arrays. Filled in paths are remembered (for the currently inspected element) so they are not "dehyrated" again as part of a polling update. + +### Inspecting hooks + +Hooks present a unique challenge for the DevTools because of the concept of _custom_ hooks. (A custom hook is essentially any function that calls at least one of the built-in hooks. By convention custom hooks also have names that begin with "use".) + +So how does DevTools identify custom functions called from within third party components? It does this by temporarily overriding React's built-in hooks and shallow rendering the component in question. Whenever one of the (overridden) built-in hooks are called, it parses the call stack to spot potential custom hooks (functions between the component itself and the built-in hook). This approach enables it to build a tree structure describing all of the calls to both the built-in _and_ custom hooks, along with the values passed to those hooks. (If you're interested in learning more about this, [here is the source code](https://github.com/bvaughn/react-devtools-experimental/blob/master/src/backend/ReactDebugHooks.js).) + +> **Note**: DevTools obtains hooks info by re-rendering a component. +> Breakpoints will be invoked during this additional (shallow) render, +> but DevTools temporarily overrides `console` methods to suppress logging. + +### Performance implications + +To mitigate the performance impact of re-rendering a component, DevTools does the following: +* Only function components that use _at least one hook_ are rendered. (Props and state can be analyzed without rendering.) +* Rendering is always shallow. +* Rendering is throttled to occur, at most, once per second. +* Rendering is skipped if the component has not updated since the last time its properties were inspected. + +## Profiler + +The Profiler UI is a powerful tool for identifying and fixing performance problems. The primary goal of the new profiler is to minimize its impact (CPU usage) while profiling is active. This can be accomplished by: +* Minimizing bridge traffic. +* Making expensive computations lazy. + +The majority of profiling information is stored on the backend. The backend push-notifies the frontend of when profiling starts or stops by sending a "_profilingStatus_" message. The frontend also asks for the current status after mounting by sending a "_getProfilingStatus_" message. (This is done to support the reload-and-profile functionality.) + +When profiling begins, the frontend takes a snapshot/copy of each root. This snapshot includes the id, name, key, and child IDs for each node in the tree. (This information is already present on the frontend, so it does not require any additional bridge traffic.) While profiling is active, each time React commits– the frontend also stores a copy of the "_operations_" message (described above). Once profiling has finished, the frontend can use the original snapshot along with each of the stored "_operations_" messages to reconstruct the tree for each of the profiled commits. + +When profiling begins, the backend records the base durations of each fiber currently in the tree. While profiling is in progress, the backend also stores some information about each commit, including: +* Commit time and duration +* Which elements were rendered during that commit +* Which interactions (if any) were part of the commit +* Which props and state changed (if enabled in profiler settings) + +This information will eventually be required by the frontend in order to render its profiling graphs, but it will not be sent across the bridge until profiling has completed (to minimize the performance impact of profiling). + +### Combining profiling data + +Once profiling is finished, the frontend requests profiling data from the backend one renderer at a time by sending a "_getProfilingData_" message. The backend responds with a "_profilingData_" message that contains per-root commit timing and duration information. The frontend then combines this information with its own snapshots to form a complete picture of the profiling session. Using this data, charts and graphs are lazily computed (and incrementally cached) on demand, based on which commits and views are selected in the Profiler UI. + +### Importing/exporting data + +Because all of the data is merged in the frontend after a profiling session is completed, it can be exported and imported (as a single JSON object), enabling profiling sessions to be shared between users. \ No newline at end of file diff --git a/extension/README.md b/extension/README.md new file mode 100644 index 0000000000000..c9082991f42e3 --- /dev/null +++ b/extension/README.md @@ -0,0 +1,23 @@ +This repo is a work-in-progress rewrite of the [React DevTools extension](https://github.com/facebook/react-devtools). A demo of the beta extension can be found online at [react-devtools-experimental.now.sh](https://react-devtools-experimental.now.sh/). + +# Installation + +Installation instructions are available online as well: +* [Chrome](https://react-devtools-experimental-chrome.now.sh/) +* [Firefox](https://react-devtools-experimental-firefox.now.sh/) + +Or you can build and install from source: +```sh +git clone git@github.com:bvaughn/react-devtools-experimental.git + +cd react-devtools-experimental + +yarn install + +yarn build:extension:chrome # builds at "shells/browser/chrome/build" +yarn build:extension:firefox # builds at "shells/browser/firefox/build" +``` + +# Support + +As this extension is in a beta period, it is not officially supported. However if you find a bug, we'd appreciate you reporting it as a [GitHub issue](https://github.com/bvaughn/react-devtools-experimental/issues/new) with repro instructions. \ No newline at end of file diff --git a/extension/babel.config.js b/extension/babel.config.js new file mode 100644 index 0000000000000..6dd41082deded --- /dev/null +++ b/extension/babel.config.js @@ -0,0 +1,46 @@ +const chromeManifest = require('./shells/browser/chrome/manifest.json'); +const firefoxManifest = require('./shells/browser/firefox/manifest.json'); + +const minChromeVersion = parseInt(chromeManifest.minimum_chrome_version, 10); +const minFirefoxVersion = parseInt( + firefoxManifest.applications.gecko.strict_min_version, + 10 +); +validateVersion(minChromeVersion); +validateVersion(minFirefoxVersion); + +function validateVersion(version) { + if (version > 0 && version < 200) { + return; + } + throw new Error('Suspicious browser version in manifest: ' + version); +} + +module.exports = api => { + const isTest = api.env('test'); + const targets = {}; + if (isTest) { + targets.node = 'current'; + } else { + targets.chrome = minChromeVersion.toString(); + targets.firefox = minFirefoxVersion.toString(); + + // This targets RN/Hermes. + targets.IE = '11'; + } + const plugins = [ + ['@babel/plugin-transform-flow-strip-types'], + ['@babel/plugin-proposal-class-properties', { loose: false }], + ]; + if (process.env.NODE_ENV !== 'production') { + plugins.push(['@babel/plugin-transform-react-jsx-source']); + } + return { + plugins, + presets: [ + ['@babel/preset-env', { targets }], + '@babel/preset-react', + '@babel/preset-flow', + ], + }; +}; diff --git a/extension/fixtures/regression/14.9.html b/extension/fixtures/regression/14.9.html new file mode 100644 index 0000000000000..efa28f49cd858 --- /dev/null +++ b/extension/fixtures/regression/14.9.html @@ -0,0 +1,38 @@ + + + + + React 14.9 + + + + + + + + + + + +
+ If you are seeing this message, you are likely viewing this file using the file protocol which does not support cross origin requests. +

+ Use the server script instead: +

+ node ./fixtures/regression/server.js
+ open http://localhost:3000/fixtures/regression/14.9.html +
+ + + + + + \ No newline at end of file diff --git a/extension/fixtures/regression/15.0.html b/extension/fixtures/regression/15.0.html new file mode 100644 index 0000000000000..464ea1f5481cc --- /dev/null +++ b/extension/fixtures/regression/15.0.html @@ -0,0 +1,38 @@ + + + + + React 15.0 + + + + + + + + + + + +
+ If you are seeing this message, you are likely viewing this file using the file protocol which does not support cross origin requests. +

+ Use the server script instead: +

+ node ./fixtures/regression/server.js
+ open http://localhost:3000/fixtures/regression/15.0.html +
+ + + + + + \ No newline at end of file diff --git a/extension/fixtures/regression/15.1.html b/extension/fixtures/regression/15.1.html new file mode 100644 index 0000000000000..7e02e81bf93a0 --- /dev/null +++ b/extension/fixtures/regression/15.1.html @@ -0,0 +1,38 @@ + + + + + React 15.1 + + + + + + + + + + + +
+ If you are seeing this message, you are likely viewing this file using the file protocol which does not support cross origin requests. +

+ Use the server script instead: +

+ node ./fixtures/regression/server.js
+ open http://localhost:3000/fixtures/regression/15.1.html +
+ + + + + + \ No newline at end of file diff --git a/extension/fixtures/regression/15.2.html b/extension/fixtures/regression/15.2.html new file mode 100644 index 0000000000000..ff1e6b87469e2 --- /dev/null +++ b/extension/fixtures/regression/15.2.html @@ -0,0 +1,38 @@ + + + + + React 15.2 + + + + + + + + + + + +
+ If you are seeing this message, you are likely viewing this file using the file protocol which does not support cross origin requests. +

+ Use the server script instead: +

+ node ./fixtures/regression/server.js
+ open http://localhost:3000/fixtures/regression/15.2.html +
+ + + + + + \ No newline at end of file diff --git a/extension/fixtures/regression/15.3.html b/extension/fixtures/regression/15.3.html new file mode 100644 index 0000000000000..8d1795962ed67 --- /dev/null +++ b/extension/fixtures/regression/15.3.html @@ -0,0 +1,38 @@ + + + + + React 15.3 + + + + + + + + + + + +
+ If you are seeing this message, you are likely viewing this file using the file protocol which does not support cross origin requests. +

+ Use the server script instead: +

+ node ./fixtures/regression/server.js
+ open http://localhost:3000/fixtures/regression/15.3.html +
+ + + + + + \ No newline at end of file diff --git a/extension/fixtures/regression/15.4.html b/extension/fixtures/regression/15.4.html new file mode 100644 index 0000000000000..ee1bfc0723b4c --- /dev/null +++ b/extension/fixtures/regression/15.4.html @@ -0,0 +1,38 @@ + + + + + React 15.4 + + + + + + + + + + + +
+ If you are seeing this message, you are likely viewing this file using the file protocol which does not support cross origin requests. +

+ Use the server script instead: +

+ node ./fixtures/regression/server.js
+ open http://localhost:3000/fixtures/regression/15.4.html +
+ + + + + + \ No newline at end of file diff --git a/extension/fixtures/regression/15.5.html b/extension/fixtures/regression/15.5.html new file mode 100644 index 0000000000000..4097bdf708709 --- /dev/null +++ b/extension/fixtures/regression/15.5.html @@ -0,0 +1,38 @@ + + + + + React 15.5 + + + + + + + + + + + +
+ If you are seeing this message, you are likely viewing this file using the file protocol which does not support cross origin requests. +

+ Use the server script instead: +

+ node ./fixtures/regression/server.js
+ open http://localhost:3000/fixtures/regression/15.5.html +
+ + + + + + \ No newline at end of file diff --git a/extension/fixtures/regression/15.6.html b/extension/fixtures/regression/15.6.html new file mode 100644 index 0000000000000..ab10ba8716dbe --- /dev/null +++ b/extension/fixtures/regression/15.6.html @@ -0,0 +1,38 @@ + + + + + React 15.6 + + + + + + + + + + + +
+ If you are seeing this message, you are likely viewing this file using the file protocol which does not support cross origin requests. +

+ Use the server script instead: +

+ node ./fixtures/regression/server.js
+ open http://localhost:3000/fixtures/regression/15.6.html +
+ + + + + + \ No newline at end of file diff --git a/extension/fixtures/regression/16.0.html b/extension/fixtures/regression/16.0.html new file mode 100644 index 0000000000000..7c55d75114b12 --- /dev/null +++ b/extension/fixtures/regression/16.0.html @@ -0,0 +1,38 @@ + + + + + React 16.0 + + + + + + + + + + + +
+ If you are seeing this message, you are likely viewing this file using the file protocol which does not support cross origin requests. +

+ Use the server script instead: +

+ node ./fixtures/regression/server.js
+ open http://localhost:3000/fixtures/regression/16.0.html +
+ + + + + + \ No newline at end of file diff --git a/extension/fixtures/regression/16.1.html b/extension/fixtures/regression/16.1.html new file mode 100644 index 0000000000000..d93addf46ecfc --- /dev/null +++ b/extension/fixtures/regression/16.1.html @@ -0,0 +1,38 @@ + + + + + React 16.1 + + + + + + + + + + + +
+ If you are seeing this message, you are likely viewing this file using the file protocol which does not support cross origin requests. +

+ Use the server script instead: +

+ node ./fixtures/regression/server.js
+ open http://localhost:3000/fixtures/regression/16.1.html +
+ + + + + + \ No newline at end of file diff --git a/extension/fixtures/regression/16.2.html b/extension/fixtures/regression/16.2.html new file mode 100644 index 0000000000000..10d8e150ed344 --- /dev/null +++ b/extension/fixtures/regression/16.2.html @@ -0,0 +1,38 @@ + + + + + React 16.2 + + + + + + + + + + + +
+ If you are seeing this message, you are likely viewing this file using the file protocol which does not support cross origin requests. +

+ Use the server script instead: +

+ node ./fixtures/regression/server.js
+ open http://localhost:3000/fixtures/regression/16.2.html +
+ + + + + + \ No newline at end of file diff --git a/extension/fixtures/regression/16.3.html b/extension/fixtures/regression/16.3.html new file mode 100644 index 0000000000000..2e42e2813a9eb --- /dev/null +++ b/extension/fixtures/regression/16.3.html @@ -0,0 +1,38 @@ + + + + + React 16.3 + + + + + + + + + + + +
+ If you are seeing this message, you are likely viewing this file using the file protocol which does not support cross origin requests. +

+ Use the server script instead: +

+ node ./fixtures/regression/server.js
+ open http://localhost:3000/fixtures/regression/16.3.html +
+ + + + + + \ No newline at end of file diff --git a/extension/fixtures/regression/16.4.html b/extension/fixtures/regression/16.4.html new file mode 100644 index 0000000000000..f59f4e9e6f005 --- /dev/null +++ b/extension/fixtures/regression/16.4.html @@ -0,0 +1,38 @@ + + + + + React 16.4 + + + + + + + + + + + +
+ If you are seeing this message, you are likely viewing this file using the file protocol which does not support cross origin requests. +

+ Use the server script instead: +

+ node ./fixtures/regression/server.js
+ open http://localhost:3000/fixtures/regression/16.4.html +
+ + + + + + \ No newline at end of file diff --git a/extension/fixtures/regression/16.5.html b/extension/fixtures/regression/16.5.html new file mode 100644 index 0000000000000..455e06f2192be --- /dev/null +++ b/extension/fixtures/regression/16.5.html @@ -0,0 +1,40 @@ + + + + + React 16.5 + + + + + + + + + + + + + +
+ If you are seeing this message, you are likely viewing this file using the file protocol which does not support cross origin requests. +

+ Use the server script instead: +

+ node ./fixtures/regression/server.js
+ open http://localhost:3000/fixtures/regression/16.5.html +
+ + + + + + \ No newline at end of file diff --git a/extension/fixtures/regression/16.6.html b/extension/fixtures/regression/16.6.html new file mode 100644 index 0000000000000..1f61c47f85a7e --- /dev/null +++ b/extension/fixtures/regression/16.6.html @@ -0,0 +1,41 @@ + + + + + React 16.6 + + + + + + + + + + + + + + +
+ If you are seeing this message, you are likely viewing this file using the file protocol which does not support cross origin requests. +

+ Use the server script instead: +

+ node ./fixtures/regression/server.js
+ open http://localhost:3000/fixtures/regression/16.6.html +
+ + + + + + \ No newline at end of file diff --git a/extension/fixtures/regression/16.7.html b/extension/fixtures/regression/16.7.html new file mode 100644 index 0000000000000..d839d7e030b69 --- /dev/null +++ b/extension/fixtures/regression/16.7.html @@ -0,0 +1,41 @@ + + + + + React 16.7 + + + + + + + + + + + + + + +
+ If you are seeing this message, you are likely viewing this file using the file protocol which does not support cross origin requests. +

+ Use the server script instead: +

+ node ./fixtures/regression/server.js
+ open http://localhost:3000/fixtures/regression/16.7.html +
+ + + + + + \ No newline at end of file diff --git a/extension/fixtures/regression/canary.html b/extension/fixtures/regression/canary.html new file mode 100644 index 0000000000000..ab59d7029e6db --- /dev/null +++ b/extension/fixtures/regression/canary.html @@ -0,0 +1,41 @@ + + + + + React canary + + + + + + + + + + + + + + +
+ If you are seeing this message, you are likely viewing this file using the file protocol which does not support cross origin requests. +

+ Use the server script instead: +

+ node ./fixtures/regression/server.js
+ open http://localhost:3000/fixtures/regression/canary.html +
+ + + + + + \ No newline at end of file diff --git a/extension/fixtures/regression/index.html b/extension/fixtures/regression/index.html new file mode 100644 index 0000000000000..125a8e8baf9b9 --- /dev/null +++ b/extension/fixtures/regression/index.html @@ -0,0 +1,28 @@ + + + + + React DevTools regression test + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/extension/fixtures/regression/next.html b/extension/fixtures/regression/next.html new file mode 100644 index 0000000000000..77bb9bbed29db --- /dev/null +++ b/extension/fixtures/regression/next.html @@ -0,0 +1,41 @@ + + + + + React next + + + + + + + + + + + + + + +
+ If you are seeing this message, you are likely viewing this file using the file protocol which does not support cross origin requests. +

+ Use the server script instead: +

+ node ./fixtures/regression/server.js
+ open http://localhost:3000/fixtures/regression/next.html +
+ + + + + + \ No newline at end of file diff --git a/extension/fixtures/regression/server.js b/extension/fixtures/regression/server.js new file mode 100755 index 0000000000000..f7e03f075dcc5 --- /dev/null +++ b/extension/fixtures/regression/server.js @@ -0,0 +1,16 @@ +#!/usr/bin/env node + +const finalhandler = require('finalhandler'); +const http = require('http'); +const serveStatic = require('serve-static'); + +// Serve fixtures folder +const serve = serveStatic(__dirname, { index: 'index.html' }); + +// Create server +const server = http.createServer(function onRequest(req, res) { + serve(req, res, finalhandler(req, res)); +}); + +// Listen +server.listen(3000); diff --git a/extension/fixtures/regression/shared.js b/extension/fixtures/regression/shared.js new file mode 100644 index 0000000000000..2afd063b6bda0 --- /dev/null +++ b/extension/fixtures/regression/shared.js @@ -0,0 +1,330 @@ +/* eslint-disable no-fallthrough, react/react-in-jsx-scope, react/jsx-no-undef */ +/* global React ReactCache ReactDOM SchedulerTracing ScheduleTracing */ + +const apps = []; + +const pieces = React.version.split('.'); +const major = + pieces[0] === '0' ? parseInt(pieces[1], 10) : parseInt(pieces[0], 10); +const minor = + pieces[0] === '0' ? parseInt(pieces[2], 10) : parseInt(pieces[1], 10); + +// Convenience wrapper to organize API features in DevTools. +function Feature({ children, label, version }) { + return ( +
+
+ {label} + {version} +
+ {children} +
+ ); +} + +// Simplify interaction tracing for tests below. +let trace = null; +if (typeof SchedulerTracing !== 'undefined') { + trace = SchedulerTracing.unstable_trace; +} else if (typeof ScheduleTracing !== 'undefined') { + trace = ScheduleTracing.unstable_trace; +} else { + trace = (_, __, callback) => callback(); +} + +// https://github.com/facebook/react/blob/master/CHANGELOG.md +switch (major) { + case 16: + switch (minor) { + case 7: + if (typeof React.useState === 'function') { + // Hooks + function Hooks() { + const [count, setCount] = React.useState(0); + const incrementCount = React.useCallback( + () => setCount(count + 1), + [count] + ); + return ( +
+ count: {count}{' '} + +
+ ); + } + apps.push( + + + + ); + } + case 6: + // memo + function LabelComponent({ label }) { + return ; + } + const AnonymousMemoized = React.memo(({ label }) => ( + + )); + const Memoized = React.memo(LabelComponent); + const CustomMemoized = React.memo(LabelComponent); + CustomMemoized.displayName = 'MemoizedLabelFunction'; + apps.push( + + + + + + ); + + // Suspense + const loadResource = ([text, ms]) => { + return new Promise((resolve, reject) => { + setTimeout(() => { + resolve(text); + }, ms); + }); + }; + const getResourceKey = ([text, ms]) => text; + const Resource = ReactCache.unstable_createResource( + loadResource, + getResourceKey + ); + class Suspending extends React.Component { + state = { useSuspense: false }; + useSuspense = () => this.setState({ useSuspense: true }); + render() { + if (this.state.useSuspense) { + const text = Resource.read(['loaded', 2000]); + return text; + } else { + return ; + } + } + } + apps.push( + + loading...
}> + + + + ); + + // lazy + const LazyWithDefaultProps = React.lazy( + () => + new Promise(resolve => { + function FooWithDefaultProps(props) { + return ( +

+ {props.greeting}, {props.name} +

+ ); + } + FooWithDefaultProps.defaultProps = { + name: 'World', + greeting: 'Bonjour', + }; + resolve({ + default: FooWithDefaultProps, + }); + }) + ); + apps.push( + + loading...}> + + + + ); + case 5: + case 4: + // unstable_Profiler + class ProfilerChild extends React.Component { + state = { count: 0 }; + incrementCount = () => + this.setState(prevState => ({ count: prevState.count + 1 })); + render() { + return ( +
+ count: {this.state.count}{' '} + +
+ ); + } + } + const onRender = (...args) => {}; + const Profiler = React.unstable_Profiler || React.Profiler; + apps.push( + + +
+ +
+
+
+ ); + case 3: + // createContext() + const LocaleContext = React.createContext(); + LocaleContext.displayName = 'LocaleContext'; + const ThemeContext = React.createContext(); + apps.push( + + + + {theme =>
theme: {theme}
} +
+
+ + + {locale =>
locale: {locale}
} +
+
+
+ ); + + // forwardRef() + const AnonymousFunction = React.forwardRef((props, ref) => ( +
{props.children}
+ )); + const NamedFunction = React.forwardRef(function named(props, ref) { + return
{props.children}
; + }); + const CustomName = React.forwardRef((props, ref) => ( +
{props.children}
+ )); + CustomName.displayName = 'CustomNameForwardRef'; + apps.push( + + AnonymousFunction + NamedFunction + CustomName + + ); + + // StrictMode + class StrictModeChild extends React.Component { + render() { + return 'StrictModeChild'; + } + } + apps.push( + + + + + + ); + + // unstable_AsyncMode (later renamed to unstable_ConcurrentMode, then ConcurrentMode) + const ConcurrentMode = + React.ConcurrentMode || + React.unstable_ConcurrentMode || + React.unstable_AsyncMode; + apps.push( + + +
+ unstable_AsyncMode was added in 16.3, renamed to + unstable_ConcurrentMode in 16.5, and then renamed to + ConcurrentMode in 16.7 +
+
+
+ ); + case 2: + // Fragment + apps.push( + + +
one
+
two
+
+
+ ); + case 1: + case 0: + default: + break; + } + break; + case 15: + break; + case 14: + break; + default: + break; +} + +function Even() { + return (even); +} + +// Simple stateful app shared by all React versions +class SimpleApp extends React.Component { + state = { count: 0 }; + incrementCount = () => { + const updaterFn = prevState => ({ count: prevState.count + 1 }); + trace('Updating count', performance.now(), () => this.setState(updaterFn)); + }; + render() { + const { count } = this.state; + return ( +
+ {count % 2 === 0 ? ( + + count: {count} + + ) : ( + count: {count} + )}{' '} + +
+ ); + } +} +apps.push( + + + +); + +// This component, with the version prop, helps organize DevTools at a glance. +function TopLevelWrapperForDevTools({ version }) { + let header =

React {version}

; + if (version.includes('canary')) { + const commitSha = version.match(/.+canary-(.+)/)[1]; + header = ( +

+ React canary{' '} + + {commitSha} + +

+ ); + } else if (version.includes('alpha')) { + header =

React next

; + } + + return ( +
+ {header} + {apps} +
+ ); +} +TopLevelWrapperForDevTools.displayName = 'React'; + +ReactDOM.render( + , + document.getElementById('root') +); diff --git a/extension/fixtures/regression/styles.css b/extension/fixtures/regression/styles.css new file mode 100644 index 0000000000000..6cbaaa5c0149a --- /dev/null +++ b/extension/fixtures/regression/styles.css @@ -0,0 +1,37 @@ +body { + font-family: sans-serif; + font-size: 12px; +} + +h1 { + margin: 0; + font-size: 20px; +} + +h2 { + margin: 1rem 0 0; +} + +iframe { + border: 1px solid #ddd; + border-radius: 0.5rem; +} + +code { + white-space: nowrap; +} + +.Feature { + margin: 1rem 0; + border-bottom: 1px solid #eee; + padding-bottom: 1rem; +} +.FeatureHeader { + font-size: 16px; + margin-bottom: 0.5rem; +} +.FeatureCode { + background-color: #eee; + padding: 0.25rem; + border-radius: 0.25rem; +} diff --git a/extension/fixtures/standalone/index.html b/extension/fixtures/standalone/index.html new file mode 100644 index 0000000000000..17e133bae891b --- /dev/null +++ b/extension/fixtures/standalone/index.html @@ -0,0 +1,284 @@ + + + + + TODO List + + + + + + + + + + + + + +
+ + + diff --git a/extension/flow-typed/chrome.js b/extension/flow-typed/chrome.js new file mode 100644 index 0000000000000..fac810912420f --- /dev/null +++ b/extension/flow-typed/chrome.js @@ -0,0 +1,94 @@ +// @flow + +declare var chrome: { + devtools: { + network: { + onNavigated: { + addListener: (cb: (url: string) => void) => void, + removeListener: (cb: () => void) => void, + }, + }, + inspectedWindow: { + eval: (code: string, cb?: (res: any, err: ?Object) => any) => void, + tabId: number, + }, + panels: { + create: ( + title: string, + icon: string, + filename: string, + cb: (panel: { + onHidden: { + addListener: (cb: (window: Object) => void) => void, + }, + onShown: { + addListener: (cb: (window: Object) => void) => void, + }, + }) => void + ) => void, + themeName: ?string, + }, + }, + tabs: { + create: (options: Object) => void, + executeScript: (tabId: number, options: Object, fn: () => void) => void, + onUpdated: { + addListener: ( + fn: (tabId: number, changeInfo: Object, tab: Object) => void + ) => void, + }, + query: (options: Object, fn: (tabArray: Array) => void) => void, + }, + browserAction: { + setIcon: (options: { + tabId: number, + path: { [key: string]: string }, + }) => void, + setPopup: (options: { + tabId: number, + popup: string, + }) => void, + }, + runtime: { + getURL: (path: string) => string, + sendMessage: (config: Object) => void, + connect: ( + config: Object + ) => { + disconnect: () => void, + onMessage: { + addListener: (fn: (message: Object) => void) => void, + }, + onDisconnect: { + addListener: (fn: (message: Object) => void) => void, + }, + postMessage: (data: Object) => void, + }, + onConnect: { + addListener: ( + fn: (port: { + name: string, + sender: { + tab: { + id: number, + url: string, + }, + }, + }) => void + ) => void, + }, + onMessage: { + addListener: ( + fn: ( + req: Object, + sender: { + url: string, + tab: { + id: number, + }, + } + ) => void + ) => void, + }, + }, +}; diff --git a/extension/flow-typed/jest.js b/extension/flow-typed/jest.js new file mode 100644 index 0000000000000..8563e20d93572 --- /dev/null +++ b/extension/flow-typed/jest.js @@ -0,0 +1,1188 @@ +type JestMockFn, TReturn> = { + (...args: TArguments): TReturn, + /** + * An object for introspecting mock calls + */ + mock: { + /** + * An array that represents all calls that have been made into this mock + * function. Each call is represented by an array of arguments that were + * passed during the call. + */ + calls: Array, + /** + * An array that contains all the object instances that have been + * instantiated from this mock function. + */ + instances: Array, + /** + * An array that contains all the object results that have been + * returned by this mock function call + */ + results: Array<{ isThrow: boolean, value: TReturn }>, + }, + /** + * Resets all information stored in the mockFn.mock.calls and + * mockFn.mock.instances arrays. Often this is useful when you want to clean + * up a mock's usage data between two assertions. + */ + mockClear(): void, + /** + * Resets all information stored in the mock. This is useful when you want to + * completely restore a mock back to its initial state. + */ + mockReset(): void, + /** + * Removes the mock and restores the initial implementation. This is useful + * when you want to mock functions in certain test cases and restore the + * original implementation in others. Beware that mockFn.mockRestore only + * works when mock was created with jest.spyOn. Thus you have to take care of + * restoration yourself when manually assigning jest.fn(). + */ + mockRestore(): void, + /** + * Accepts a function that should be used as the implementation of the mock. + * The mock itself will still record all calls that go into and instances + * that come from itself -- the only difference is that the implementation + * will also be executed when the mock is called. + */ + mockImplementation( + fn: (...args: TArguments) => TReturn + ): JestMockFn, + /** + * Accepts a function that will be used as an implementation of the mock for + * one call to the mocked function. Can be chained so that multiple function + * calls produce different results. + */ + mockImplementationOnce( + fn: (...args: TArguments) => TReturn + ): JestMockFn, + /** + * Accepts a string to use in test result output in place of "jest.fn()" to + * indicate which mock function is being referenced. + */ + mockName(name: string): JestMockFn, + /** + * Just a simple sugar function for returning `this` + */ + mockReturnThis(): void, + /** + * Accepts a value that will be returned whenever the mock function is called. + */ + mockReturnValue(value: TReturn): JestMockFn, + /** + * Sugar for only returning a value once inside your mock + */ + mockReturnValueOnce(value: TReturn): JestMockFn, + /** + * Sugar for jest.fn().mockImplementation(() => Promise.resolve(value)) + */ + mockResolvedValue(value: TReturn): JestMockFn>, + /** + * Sugar for jest.fn().mockImplementationOnce(() => Promise.resolve(value)) + */ + mockResolvedValueOnce( + value: TReturn + ): JestMockFn>, + /** + * Sugar for jest.fn().mockImplementation(() => Promise.reject(value)) + */ + mockRejectedValue(value: TReturn): JestMockFn>, + /** + * Sugar for jest.fn().mockImplementationOnce(() => Promise.reject(value)) + */ + mockRejectedValueOnce(value: TReturn): JestMockFn>, +}; + +type JestAsymmetricEqualityType = { + /** + * A custom Jasmine equality tester + */ + asymmetricMatch(value: mixed): boolean, +}; + +type JestCallsType = { + allArgs(): mixed, + all(): mixed, + any(): boolean, + count(): number, + first(): mixed, + mostRecent(): mixed, + reset(): void, +}; + +type JestClockType = { + install(): void, + mockDate(date: Date): void, + tick(milliseconds?: number): void, + uninstall(): void, +}; + +type JestMatcherResult = { + message?: string | (() => string), + pass: boolean, +}; + +type JestMatcher = ( + actual: any, + expected: any +) => JestMatcherResult | Promise; + +type JestPromiseType = { + /** + * Use rejects to unwrap the reason of a rejected promise so any other + * matcher can be chained. If the promise is fulfilled the assertion fails. + */ + // eslint-disable-next-line no-use-before-define + rejects: JestExpectType, + /** + * Use resolves to unwrap the value of a fulfilled promise so any other + * matcher can be chained. If the promise is rejected the assertion fails. + */ + // eslint-disable-next-line no-use-before-define + resolves: JestExpectType, +}; + +/** + * Jest allows functions and classes to be used as test names in test() and + * describe() + */ +type JestTestName = string | Function; + +/** + * Plugin: jest-styled-components + */ + +type JestStyledComponentsMatcherValue = + | string + | JestAsymmetricEqualityType + | RegExp + | typeof undefined; + +type JestStyledComponentsMatcherOptions = { + media?: string, + modifier?: string, + supports?: string, +}; + +type JestStyledComponentsMatchersType = { + toHaveStyleRule( + property: string, + value: JestStyledComponentsMatcherValue, + options?: JestStyledComponentsMatcherOptions + ): void, +}; + +/** + * Plugin: jest-enzyme + */ +type EnzymeMatchersType = { + // 5.x + toBeEmpty(): void, + toBePresent(): void, + // 6.x + toBeChecked(): void, + toBeDisabled(): void, + toBeEmptyRender(): void, + toContainMatchingElement(selector: string): void, + toContainMatchingElements(n: number, selector: string): void, + toContainExactlyOneMatchingElement(selector: string): void, + toContainReact(element: React$Element): void, + toExist(): void, + toHaveClassName(className: string): void, + toHaveHTML(html: string): void, + toHaveProp: ((propKey: string, propValue?: any) => void) & + ((props: {}) => void), + toHaveRef(refName: string): void, + toHaveState: ((stateKey: string, stateValue?: any) => void) & + ((state: {}) => void), + toHaveStyle: ((styleKey: string, styleValue?: any) => void) & + ((style: {}) => void), + toHaveTagName(tagName: string): void, + toHaveText(text: string): void, + toHaveValue(value: any): void, + toIncludeText(text: string): void, + toMatchElement( + element: React$Element, + options?: {| ignoreProps?: boolean, verbose?: boolean |} + ): void, + toMatchSelector(selector: string): void, + // 7.x + toHaveDisplayName(name: string): void, +}; + +// DOM testing library extensions https://github.com/kentcdodds/dom-testing-library#custom-jest-matchers +type DomTestingLibraryType = { + toBeDisabled(): void, + toBeEmpty(): void, + toBeInTheDocument(): void, + toBeVisible(): void, + toContainElement(element: HTMLElement | null): void, + toContainHTML(htmlText: string): void, + toHaveAttribute(name: string, expectedValue?: string): void, + toHaveClass(...classNames: string[]): void, + toHaveFocus(): void, + toHaveFormValues(expectedValues: { [name: string]: any }): void, + toHaveStyle(css: string): void, + toHaveTextContent( + content: string | RegExp, + options?: { normalizeWhitespace: boolean } + ): void, + toBeInTheDOM(): void, +}; + +// Jest JQuery Matchers: https://github.com/unindented/custom-jquery-matchers +type JestJQueryMatchersType = { + toExist(): void, + toHaveLength(len: number): void, + toHaveId(id: string): void, + toHaveClass(className: string): void, + toHaveTag(tag: string): void, + toHaveAttr(key: string, val?: any): void, + toHaveProp(key: string, val?: any): void, + toHaveText(text: string | RegExp): void, + toHaveData(key: string, val?: any): void, + toHaveValue(val: any): void, + toHaveCss(css: { [key: string]: any }): void, + toBeChecked(): void, + toBeDisabled(): void, + toBeEmpty(): void, + toBeHidden(): void, + toBeSelected(): void, + toBeVisible(): void, + toBeFocused(): void, + toBeInDom(): void, + toBeMatchedBy(sel: string): void, + toHaveDescendant(sel: string): void, + toHaveDescendantWithText(sel: string, text: string | RegExp): void, +}; + +// Jest Extended Matchers: https://github.com/jest-community/jest-extended +type JestExtendedMatchersType = { + /** + * Note: Currently unimplemented + * Passing assertion + * + * @param {String} message + */ + // pass(message: string): void; + + /** + * Note: Currently unimplemented + * Failing assertion + * + * @param {String} message + */ + // fail(message: string): void; + + /** + * Use .toBeEmpty when checking if a String '', Array [] or Object {} is empty. + */ + toBeEmpty(): void, + + /** + * Use .toBeOneOf when checking if a value is a member of a given Array. + * @param {Array.<*>} members + */ + toBeOneOf(members: any[]): void, + + /** + * Use `.toBeNil` when checking a value is `null` or `undefined`. + */ + toBeNil(): void, + + /** + * Use `.toSatisfy` when you want to use a custom matcher by supplying a predicate function that returns a `Boolean`. + * @param {Function} predicate + */ + toSatisfy(predicate: (n: any) => boolean): void, + + /** + * Use `.toBeArray` when checking if a value is an `Array`. + */ + toBeArray(): void, + + /** + * Use `.toBeArrayOfSize` when checking if a value is an `Array` of size x. + * @param {Number} x + */ + toBeArrayOfSize(x: number): void, + + /** + * Use `.toIncludeAllMembers` when checking if an `Array` contains all of the same members of a given set. + * @param {Array.<*>} members + */ + toIncludeAllMembers(members: any[]): void, + + /** + * Use `.toIncludeAnyMembers` when checking if an `Array` contains any of the members of a given set. + * @param {Array.<*>} members + */ + toIncludeAnyMembers(members: any[]): void, + + /** + * Use `.toSatisfyAll` when you want to use a custom matcher by supplying a predicate function that returns a `Boolean` for all values in an array. + * @param {Function} predicate + */ + toSatisfyAll(predicate: (n: any) => boolean): void, + + /** + * Use `.toBeBoolean` when checking if a value is a `Boolean`. + */ + toBeBoolean(): void, + + /** + * Use `.toBeTrue` when checking a value is equal (===) to `true`. + */ + toBeTrue(): void, + + /** + * Use `.toBeFalse` when checking a value is equal (===) to `false`. + */ + toBeFalse(): void, + + /** + * Use .toBeDate when checking if a value is a Date. + */ + toBeDate(): void, + + /** + * Use `.toBeFunction` when checking if a value is a `Function`. + */ + toBeFunction(): void, + + /** + * Use `.toHaveBeenCalledBefore` when checking if a `Mock` was called before another `Mock`. + * + * Note: Required Jest version >22 + * Note: Your mock functions will have to be asynchronous to cause the timestamps inside of Jest to occur in a differentJS event loop, otherwise the mock timestamps will all be the same + * + * @param {Mock} mock + */ + toHaveBeenCalledBefore(mock: JestMockFn): void, + + /** + * Use `.toBeNumber` when checking if a value is a `Number`. + */ + toBeNumber(): void, + + /** + * Use `.toBeNaN` when checking a value is `NaN`. + */ + toBeNaN(): void, + + /** + * Use `.toBeFinite` when checking if a value is a `Number`, not `NaN` or `Infinity`. + */ + toBeFinite(): void, + + /** + * Use `.toBePositive` when checking if a value is a positive `Number`. + */ + toBePositive(): void, + + /** + * Use `.toBeNegative` when checking if a value is a negative `Number`. + */ + toBeNegative(): void, + + /** + * Use `.toBeEven` when checking if a value is an even `Number`. + */ + toBeEven(): void, + + /** + * Use `.toBeOdd` when checking if a value is an odd `Number`. + */ + toBeOdd(): void, + + /** + * Use `.toBeWithin` when checking if a number is in between the given bounds of: start (inclusive) and end (exclusive). + * + * @param {Number} start + * @param {Number} end + */ + toBeWithin(start: number, end: number): void, + + /** + * Use `.toBeObject` when checking if a value is an `Object`. + */ + toBeObject(): void, + + /** + * Use `.toContainKey` when checking if an object contains the provided key. + * + * @param {String} key + */ + toContainKey(key: string): void, + + /** + * Use `.toContainKeys` when checking if an object has all of the provided keys. + * + * @param {Array.} keys + */ + toContainKeys(keys: string[]): void, + + /** + * Use `.toContainAllKeys` when checking if an object only contains all of the provided keys. + * + * @param {Array.} keys + */ + toContainAllKeys(keys: string[]): void, + + /** + * Use `.toContainAnyKeys` when checking if an object contains at least one of the provided keys. + * + * @param {Array.} keys + */ + toContainAnyKeys(keys: string[]): void, + + /** + * Use `.toContainValue` when checking if an object contains the provided value. + * + * @param {*} value + */ + toContainValue(value: any): void, + + /** + * Use `.toContainValues` when checking if an object contains all of the provided values. + * + * @param {Array.<*>} values + */ + toContainValues(values: any[]): void, + + /** + * Use `.toContainAllValues` when checking if an object only contains all of the provided values. + * + * @param {Array.<*>} values + */ + toContainAllValues(values: any[]): void, + + /** + * Use `.toContainAnyValues` when checking if an object contains at least one of the provided values. + * + * @param {Array.<*>} values + */ + toContainAnyValues(values: any[]): void, + + /** + * Use `.toContainEntry` when checking if an object contains the provided entry. + * + * @param {Array.} entry + */ + toContainEntry(entry: [string, string]): void, + + /** + * Use `.toContainEntries` when checking if an object contains all of the provided entries. + * + * @param {Array.>} entries + */ + toContainEntries(entries: [string, string][]): void, + + /** + * Use `.toContainAllEntries` when checking if an object only contains all of the provided entries. + * + * @param {Array.>} entries + */ + toContainAllEntries(entries: [string, string][]): void, + + /** + * Use `.toContainAnyEntries` when checking if an object contains at least one of the provided entries. + * + * @param {Array.>} entries + */ + toContainAnyEntries(entries: [string, string][]): void, + + /** + * Use `.toBeExtensible` when checking if an object is extensible. + */ + toBeExtensible(): void, + + /** + * Use `.toBeFrozen` when checking if an object is frozen. + */ + toBeFrozen(): void, + + /** + * Use `.toBeSealed` when checking if an object is sealed. + */ + toBeSealed(): void, + + /** + * Use `.toBeString` when checking if a value is a `String`. + */ + toBeString(): void, + + /** + * Use `.toEqualCaseInsensitive` when checking if a string is equal (===) to another ignoring the casing of both strings. + * + * @param {String} string + */ + toEqualCaseInsensitive(string: string): void, + + /** + * Use `.toStartWith` when checking if a `String` starts with a given `String` prefix. + * + * @param {String} prefix + */ + toStartWith(prefix: string): void, + + /** + * Use `.toEndWith` when checking if a `String` ends with a given `String` suffix. + * + * @param {String} suffix + */ + toEndWith(suffix: string): void, + + /** + * Use `.toInclude` when checking if a `String` includes the given `String` substring. + * + * @param {String} substring + */ + toInclude(substring: string): void, + + /** + * Use `.toIncludeRepeated` when checking if a `String` includes the given `String` substring the correct number of times. + * + * @param {String} substring + * @param {Number} times + */ + toIncludeRepeated(substring: string, times: number): void, + + /** + * Use `.toIncludeMultiple` when checking if a `String` includes all of the given substrings. + * + * @param {Array.} substring + */ + toIncludeMultiple(substring: string[]): void, +}; + +interface JestExpectType { + not: JestExpectType & + EnzymeMatchersType & + DomTestingLibraryType & + JestJQueryMatchersType & + JestStyledComponentsMatchersType & + JestExtendedMatchersType; + /** + * If you have a mock function, you can use .lastCalledWith to test what + * arguments it was last called with. + */ + lastCalledWith(...args: Array): void; + /** + * toBe just checks that a value is what you expect. It uses === to check + * strict equality. + */ + toBe(value: any): void; + /** + * Use .toBeCalledWith to ensure that a mock function was called with + * specific arguments. + */ + toBeCalledWith(...args: Array): void; + /** + * Using exact equality with floating point numbers is a bad idea. Rounding + * means that intuitive things fail. + */ + toBeCloseTo(num: number, delta: any): void; + /** + * Use .toBeDefined to check that a variable is not undefined. + */ + toBeDefined(): void; + /** + * Use .toBeFalsy when you don't care what a value is, you just want to + * ensure a value is false in a boolean context. + */ + toBeFalsy(): void; + /** + * To compare floating point numbers, you can use toBeGreaterThan. + */ + toBeGreaterThan(number: number): void; + /** + * To compare floating point numbers, you can use toBeGreaterThanOrEqual. + */ + toBeGreaterThanOrEqual(number: number): void; + /** + * To compare floating point numbers, you can use toBeLessThan. + */ + toBeLessThan(number: number): void; + /** + * To compare floating point numbers, you can use toBeLessThanOrEqual. + */ + toBeLessThanOrEqual(number: number): void; + /** + * Use .toBeInstanceOf(Class) to check that an object is an instance of a + * class. + */ + toBeInstanceOf(cls: Class<*>): void; + /** + * .toBeNull() is the same as .toBe(null) but the error messages are a bit + * nicer. + */ + toBeNull(): void; + /** + * Use .toBeTruthy when you don't care what a value is, you just want to + * ensure a value is true in a boolean context. + */ + toBeTruthy(): void; + /** + * Use .toBeUndefined to check that a variable is undefined. + */ + toBeUndefined(): void; + /** + * Use .toContain when you want to check that an item is in a list. For + * testing the items in the list, this uses ===, a strict equality check. + */ + toContain(item: any): void; + /** + * Use .toContainEqual when you want to check that an item is in a list. For + * testing the items in the list, this matcher recursively checks the + * equality of all fields, rather than checking for object identity. + */ + toContainEqual(item: any): void; + /** + * Use .toEqual when you want to check that two objects have the same value. + * This matcher recursively checks the equality of all fields, rather than + * checking for object identity. + */ + toEqual(value: any): void; + /** + * Use .toHaveBeenCalled to ensure that a mock function got called. + */ + toHaveBeenCalled(): void; + toBeCalled(): void; + /** + * Use .toHaveBeenCalledTimes to ensure that a mock function got called exact + * number of times. + */ + toHaveBeenCalledTimes(number: number): void; + toBeCalledTimes(number: number): void; + /** + * + */ + toHaveBeenNthCalledWith(nthCall: number, ...args: Array): void; + nthCalledWith(nthCall: number, ...args: Array): void; + /** + * + */ + toHaveReturned(): void; + toReturn(): void; + /** + * + */ + toHaveReturnedTimes(number: number): void; + toReturnTimes(number: number): void; + /** + * + */ + toHaveReturnedWith(value: any): void; + toReturnWith(value: any): void; + /** + * + */ + toHaveLastReturnedWith(value: any): void; + lastReturnedWith(value: any): void; + /** + * + */ + toHaveNthReturnedWith(nthCall: number, value: any): void; + nthReturnedWith(nthCall: number, value: any): void; + /** + * Use .toHaveBeenCalledWith to ensure that a mock function was called with + * specific arguments. + */ + toHaveBeenCalledWith(...args: Array): void; + toBeCalledWith(...args: Array): void; + /** + * Use .toHaveBeenLastCalledWith to ensure that a mock function was last called + * with specific arguments. + */ + toHaveBeenLastCalledWith(...args: Array): void; + lastCalledWith(...args: Array): void; + /** + * Check that an object has a .length property and it is set to a certain + * numeric value. + */ + toHaveLength(number: number): void; + /** + * + */ + toHaveProperty(propPath: string, value?: any): void; + /** + * Use .toMatch to check that a string matches a regular expression or string. + */ + toMatch(regexpOrString: RegExp | string): void; + /** + * Use .toMatchObject to check that a javascript object matches a subset of the properties of an object. + */ + toMatchObject(object: Object | Array): void; + /** + * Use .toStrictEqual to check that a javascript object matches a subset of the properties of an object. + */ + toStrictEqual(value: any): void; + /** + * This ensures that an Object matches the most recent snapshot. + */ + toMatchSnapshot(propertyMatchers?: any, name?: string): void; + /** + * This ensures that an Object matches the most recent snapshot. + */ + toMatchSnapshot(name: string): void; + + toMatchInlineSnapshot(snapshot?: string): void; + toMatchInlineSnapshot(propertyMatchers?: any, snapshot?: string): void; + /** + * Use .toThrow to test that a function throws when it is called. + * If you want to test that a specific error gets thrown, you can provide an + * argument to toThrow. The argument can be a string for the error message, + * a class for the error, or a regex that should match the error. + * + * Alias: .toThrowError + */ + toThrow(message?: string | Error | Class | RegExp): void; + toThrowError(message?: string | Error | Class | RegExp): void; + /** + * Use .toThrowErrorMatchingSnapshot to test that a function throws a error + * matching the most recent snapshot when it is called. + */ + toThrowErrorMatchingSnapshot(): void; + toThrowErrorMatchingInlineSnapshot(snapshot?: string): void; +} + +type JestObjectType = { + /** + * Disables automatic mocking in the module loader. + * + * After this method is called, all `require()`s will return the real + * versions of each module (rather than a mocked version). + */ + disableAutomock(): JestObjectType, + /** + * An un-hoisted version of disableAutomock + */ + autoMockOff(): JestObjectType, + /** + * Enables automatic mocking in the module loader. + */ + enableAutomock(): JestObjectType, + /** + * An un-hoisted version of enableAutomock + */ + autoMockOn(): JestObjectType, + /** + * Clears the mock.calls and mock.instances properties of all mocks. + * Equivalent to calling .mockClear() on every mocked function. + */ + clearAllMocks(): JestObjectType, + /** + * Resets the state of all mocks. Equivalent to calling .mockReset() on every + * mocked function. + */ + resetAllMocks(): JestObjectType, + /** + * Restores all mocks back to their original value. + */ + restoreAllMocks(): JestObjectType, + /** + * Removes any pending timers from the timer system. + */ + clearAllTimers(): void, + /** + * Returns the number of fake timers still left to run. + */ + getTimerCount(): number, + /** + * The same as `mock` but not moved to the top of the expectation by + * babel-jest. + */ + doMock(moduleName: string, moduleFactory?: any): JestObjectType, + /** + * The same as `unmock` but not moved to the top of the expectation by + * babel-jest. + */ + dontMock(moduleName: string): JestObjectType, + /** + * Returns a new, unused mock function. Optionally takes a mock + * implementation. + */ + fn, TReturn>( + implementation?: (...args: TArguments) => TReturn + ): JestMockFn, + /** + * Determines if the given function is a mocked function. + */ + isMockFunction(fn: Function): boolean, + /** + * Given the name of a module, use the automatic mocking system to generate a + * mocked version of the module for you. + */ + genMockFromModule(moduleName: string): any, + /** + * Mocks a module with an auto-mocked version when it is being required. + * + * The second argument can be used to specify an explicit module factory that + * is being run instead of using Jest's automocking feature. + * + * The third argument can be used to create virtual mocks -- mocks of modules + * that don't exist anywhere in the system. + */ + mock( + moduleName: string, + moduleFactory?: any, + options?: Object + ): JestObjectType, + /** + * Returns the actual module instead of a mock, bypassing all checks on + * whether the module should receive a mock implementation or not. + */ + requireActual(moduleName: string): any, + /** + * Returns a mock module instead of the actual module, bypassing all checks + * on whether the module should be required normally or not. + */ + requireMock(moduleName: string): any, + /** + * Resets the module registry - the cache of all required modules. This is + * useful to isolate modules where local state might conflict between tests. + */ + resetModules(): JestObjectType, + + /** + * Creates a sandbox registry for the modules that are loaded inside the + * callback function. This is useful to isolate specific modules for every + * test so that local module state doesn't conflict between tests. + */ + isolateModules(fn: () => void): JestObjectType, + + /** + * Exhausts the micro-task queue (usually interfaced in node via + * process.nextTick). + */ + runAllTicks(): void, + /** + * Exhausts the macro-task queue (i.e., all tasks queued by setTimeout(), + * setInterval(), and setImmediate()). + */ + runAllTimers(): void, + /** + * Exhausts all tasks queued by setImmediate(). + */ + runAllImmediates(): void, + /** + * Executes only the macro task queue (i.e. all tasks queued by setTimeout() + * or setInterval() and setImmediate()). + */ + advanceTimersByTime(msToRun: number): void, + /** + * Executes only the macro task queue (i.e. all tasks queued by setTimeout() + * or setInterval() and setImmediate()). + * + * Renamed to `advanceTimersByTime`. + */ + runTimersToTime(msToRun: number): void, + /** + * Executes only the macro-tasks that are currently pending (i.e., only the + * tasks that have been queued by setTimeout() or setInterval() up to this + * point) + */ + runOnlyPendingTimers(): void, + /** + * Explicitly supplies the mock object that the module system should return + * for the specified module. Note: It is recommended to use jest.mock() + * instead. + */ + setMock(moduleName: string, moduleExports: any): JestObjectType, + /** + * Indicates that the module system should never return a mocked version of + * the specified module from require() (e.g. that it should always return the + * real module). + */ + unmock(moduleName: string): JestObjectType, + /** + * Instructs Jest to use fake versions of the standard timer functions + * (setTimeout, setInterval, clearTimeout, clearInterval, nextTick, + * setImmediate and clearImmediate). + */ + useFakeTimers(): JestObjectType, + /** + * Instructs Jest to use the real versions of the standard timer functions. + */ + useRealTimers(): JestObjectType, + /** + * Creates a mock function similar to jest.fn but also tracks calls to + * object[methodName]. + */ + spyOn( + object: Object, + methodName: string, + accessType?: 'get' | 'set' + ): JestMockFn, + /** + * Set the default timeout interval for tests and before/after hooks in milliseconds. + * Note: The default timeout interval is 5 seconds if this method is not called. + */ + setTimeout(timeout: number): JestObjectType, +}; + +type JestSpyType = { + calls: JestCallsType, +}; + +/** Runs this function after every test inside this context */ +declare function afterEach( + fn: (done: () => void) => ?Promise, + timeout?: number +): void; +/** Runs this function before every test inside this context */ +declare function beforeEach( + fn: (done: () => void) => ?Promise, + timeout?: number +): void; +/** Runs this function after all tests have finished inside this context */ +declare function afterAll( + fn: (done: () => void) => ?Promise, + timeout?: number +): void; +/** Runs this function before any tests have started inside this context */ +declare function beforeAll( + fn: (done: () => void) => ?Promise, + timeout?: number +): void; + +/** A context for grouping tests together */ +declare var describe: { + /** + * Creates a block that groups together several related tests in one "test suite" + */ + (name: JestTestName, fn: () => void): void, + + /** + * Only run this describe block + */ + only(name: JestTestName, fn: () => void): void, + + /** + * Skip running this describe block + */ + skip(name: JestTestName, fn: () => void): void, + + /** + * each runs this test against array of argument arrays per each run + * + * @param {table} table of Test + */ + each( + ...table: Array | mixed> | [Array, string] + ): ( + name: JestTestName, + fn?: (...args: Array) => ?Promise, + timeout?: number + ) => void, +}; + +/** An individual test unit */ +declare var it: { + /** + * An individual test unit + * + * @param {JestTestName} Name of Test + * @param {Function} Test + * @param {number} Timeout for the test, in milliseconds. + */ + ( + name: JestTestName, + fn?: (done: () => void) => ?Promise, + timeout?: number + ): void, + + /** + * Only run this test + * + * @param {JestTestName} Name of Test + * @param {Function} Test + * @param {number} Timeout for the test, in milliseconds. + */ + only( + name: JestTestName, + fn?: (done: () => void) => ?Promise, + timeout?: number + ): { + each( + ...table: Array | mixed> | [Array, string] + ): ( + name: JestTestName, + fn?: (...args: Array) => ?Promise, + timeout?: number + ) => void, + }, + + /** + * Skip running this test + * + * @param {JestTestName} Name of Test + * @param {Function} Test + * @param {number} Timeout for the test, in milliseconds. + */ + skip( + name: JestTestName, + fn?: (done: () => void) => ?Promise, + timeout?: number + ): void, + + /** + * Highlight planned tests in the summary output + * + * @param {String} Name of Test to do + */ + todo(name: string): void, + + /** + * Run the test concurrently + * + * @param {JestTestName} Name of Test + * @param {Function} Test + * @param {number} Timeout for the test, in milliseconds. + */ + concurrent( + name: JestTestName, + fn?: (done: () => void) => ?Promise, + timeout?: number + ): void, + + /** + * each runs this test against array of argument arrays per each run + * + * @param {table} table of Test + */ + each( + ...table: Array | mixed> | [Array, string] + ): ( + name: JestTestName, + fn?: (...args: Array) => ?Promise, + timeout?: number + ) => void, +}; + +declare function fit( + name: JestTestName, + fn: (done: () => void) => ?Promise, + timeout?: number +): void; +/** An individual test unit */ +declare var test: typeof it; +/** A disabled group of tests */ +declare var xdescribe: typeof describe; +/** A focused group of tests */ +declare var fdescribe: typeof describe; +/** A disabled individual test */ +declare var xit: typeof it; +/** A disabled individual test */ +declare var xtest: typeof it; + +type JestPrettyFormatColors = { + comment: { close: string, open: string }, + content: { close: string, open: string }, + prop: { close: string, open: string }, + tag: { close: string, open: string }, + value: { close: string, open: string }, +}; + +type JestPrettyFormatIndent = string => string; +// eslint-disable-next-line no-unused-vars +type JestPrettyFormatRefs = Array; +type JestPrettyFormatPrint = any => string; +// eslint-disable-next-line no-unused-vars +type JestPrettyFormatStringOrNull = string | null; + +type JestPrettyFormatOptions = {| + callToJSON: boolean, + edgeSpacing: string, + escapeRegex: boolean, + highlight: boolean, + indent: number, + maxDepth: number, + min: boolean, + // eslint-disable-next-line no-use-before-define + plugins: JestPrettyFormatPlugins, + printFunctionName: boolean, + spacing: string, + theme: {| + comment: string, + content: string, + prop: string, + tag: string, + value: string, + |}, +|}; + +type JestPrettyFormatPlugin = { + print: ( + val: any, + serialize: JestPrettyFormatPrint, + indent: JestPrettyFormatIndent, + opts: JestPrettyFormatOptions, + colors: JestPrettyFormatColors + ) => string, + test: any => boolean, +}; + +type JestPrettyFormatPlugins = Array; + +/** The expect function is used every time you want to test a value */ +declare var expect: { + /** The object that you want to make assertions against */ + ( + value: any + ): JestExpectType & + JestPromiseType & + EnzymeMatchersType & + DomTestingLibraryType & + JestJQueryMatchersType & + JestStyledComponentsMatchersType & + JestExtendedMatchersType, + + /** Add additional Jasmine matchers to Jest's roster */ + extend(matchers: { [name: string]: JestMatcher }): void, + /** Add a module that formats application-specific data structures. */ + addSnapshotSerializer(pluginModule: JestPrettyFormatPlugin): void, + assertions(expectedAssertions: number): void, + hasAssertions(): void, + any(value: mixed): JestAsymmetricEqualityType, + anything(): any, + arrayContaining(value: Array): Array, + objectContaining(value: Object): Object, + /** Matches any received string that contains the exact expected string. */ + stringContaining(value: string): string, + stringMatching(value: string | RegExp): string, + not: { + arrayContaining: (value: $ReadOnlyArray) => Array, + objectContaining: (value: {}) => Object, + stringContaining: (value: string) => string, + stringMatching: (value: string | RegExp) => string, + }, +}; + +// TODO handle return type +// http://jasmine.github.io/2.4/introduction.html#section-Spies +declare function spyOn(value: mixed, method: string): Object; + +/** Holds all functions related to manipulating test runner */ +declare var jest: JestObjectType; + +/** + * The global Jasmine object, this is generally not exposed as the public API, + * using features inside here could break in later versions of Jest. + */ +declare var jasmine: { + DEFAULT_TIMEOUT_INTERVAL: number, + any(value: mixed): JestAsymmetricEqualityType, + anything(): any, + arrayContaining(value: Array): Array, + clock(): JestClockType, + createSpy(name: string): JestSpyType, + createSpyObj( + baseName: string, + methodNames: Array + ): { [methodName: string]: JestSpyType }, + objectContaining(value: Object): Object, + stringMatching(value: string): string, +}; diff --git a/extension/flow-typed/npm/react-test-renderer_v16.x.x.js b/extension/flow-typed/npm/react-test-renderer_v16.x.x.js new file mode 100644 index 0000000000000..67eb20ab753fa --- /dev/null +++ b/extension/flow-typed/npm/react-test-renderer_v16.x.x.js @@ -0,0 +1,81 @@ +// flow-typed signature: b6bb53397d83d2d821e258cc73818d1b +// flow-typed version: 9c71eca8ef/react-test-renderer_v16.x.x/flow_>=v0.47.x + +// Type definitions for react-test-renderer 16.x.x +// Ported from: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react-test-renderer + +type ReactComponentInstance = React$Component; + +type ReactTestRendererJSON = { + type: string, + props: { [propName: string]: any }, + children: null | ReactTestRendererJSON[], +}; + +type ReactTestRendererTree = ReactTestRendererJSON & { + nodeType: 'component' | 'host', + instance: ?ReactComponentInstance, + rendered: null | ReactTestRendererTree, +}; + +type ReactTestInstance = { + instance: ?ReactComponentInstance, + type: string, + props: { [propName: string]: any }, + parent: null | ReactTestInstance, + children: Array, + + find(predicate: (node: ReactTestInstance) => boolean): ReactTestInstance, + findByType(type: React$ElementType): ReactTestInstance, + findByProps(props: { [propName: string]: any }): ReactTestInstance, + + findAll( + predicate: (node: ReactTestInstance) => boolean, + options?: { deep: boolean } + ): ReactTestInstance[], + findAllByType( + type: React$ElementType, + options?: { deep: boolean } + ): ReactTestInstance[], + findAllByProps( + props: { [propName: string]: any }, + options?: { deep: boolean } + ): ReactTestInstance[], +}; + +type TestRendererOptions = { + createNodeMock(element: React$Element): any, +}; + +declare module 'react-test-renderer' { + declare export type ReactTestRenderer = { + toJSON(): null | ReactTestRendererJSON, + toTree(): null | ReactTestRendererTree, + unmount(nextElement?: React$Element): void, + update(nextElement: React$Element): void, + getInstance(): ?ReactComponentInstance, + root: ReactTestInstance, + }; + + declare type Thenable = { + then(resolve: () => mixed, reject?: () => mixed): mixed, + }; + + declare function create( + nextElement: React$Element, + options?: TestRendererOptions + ): ReactTestRenderer; + + declare function act(callback: () => ?Thenable): Thenable; +} + +declare module 'react-test-renderer/shallow' { + declare export default class ShallowRenderer { + static createRenderer(): ShallowRenderer; + getMountedInstance(): ReactTestInstance; + getRenderOutput>(): E; + getRenderOutput(): React$Element; + render(element: React$Element, context?: any): void; + unmount(): void; + } +} diff --git a/extension/flow.js b/extension/flow.js new file mode 100644 index 0000000000000..dc0c5759006b4 --- /dev/null +++ b/extension/flow.js @@ -0,0 +1,28 @@ +// @flow + +declare module 'events' { + declare class EventEmitter { + addListener>( + event: Event, + listener: (...$ElementType) => any + ): void; + emit: >( + event: Event, + ...$ElementType + ) => void; + removeListener(event: $Keys, listener: Function): void; + removeAllListeners(event?: $Keys): void; + } + + declare export default typeof EventEmitter; +} + +declare var __DEV__: boolean; +declare var __TEST__: boolean; + +declare var jasmine: {| + getEnv: () => {| + afterEach: (callback: Function) => void, + beforeEach: (callback: Function) => void, + |}, +|}; diff --git a/extension/package.json b/extension/package.json new file mode 100644 index 0000000000000..c0c5ffadc0dbc --- /dev/null +++ b/extension/package.json @@ -0,0 +1,164 @@ +{ + "version": "4.0.0", + "repository": "bvaughn/react-devtools-experimental", + "license": "MIT", + "private": true, + "workspaces": [ + "packages/*" + ], + "jest": { + "modulePathIgnorePatterns": [ + "/shells" + ], + "modulePaths": [ + "" + ], + "moduleNameMapper": { + "^src/(.*)$": "/src/$1", + "\\.css$": "/src/__tests__/__mocks__/cssMock.js" + }, + "setupFiles": [ + "/src/__tests__/setupEnv" + ], + "setupFilesAfterEnv": [ + "/src/__tests__/setupTests" + ], + "snapshotSerializers": [ + "/src/__tests__/inspectedElementSerializer", + "/src/__tests__/storeSerializer" + ], + "testMatch": [ + "**/__tests__/**/*-test.js" + ] + }, + "scripts": { + "build:core:backend": "cd ./packages/react-devtools-core && yarn build:backend", + "build:core:standalone": "cd ./packages/react-devtools-core && yarn build:standalone", + "build:core": "cd ./packages/react-devtools-core && yarn build", + "build:inline": "cd ./packages/react-devtools-inline && yarn build", + "build:demo": "cd ./shells/dev && cross-env NODE_ENV=development cross-env TARGET=remote webpack --config webpack.config.js", + "build:extension": "cross-env NODE_ENV=production yarn run build:extension:chrome && yarn run build:extension:firefox", + "build:extension:dev": "cross-env NODE_ENV=development yarn run build:extension:chrome && yarn run build:extension:firefox", + "build:extension:chrome": "cross-env NODE_ENV=production node ./shells/browser/chrome/build", + "build:extension:chrome:crx": "cross-env NODE_ENV=production node ./shells/browser/chrome/build --crx", + "build:extension:chrome:dev": "cross-env NODE_ENV=development node ./shells/browser/chrome/build", + "build:extension:firefox": "cross-env NODE_ENV=production node ./shells/browser/firefox/build", + "build:extension:firefox:dev": "cross-env NODE_ENV=development node ./shells/browser/firefox/build", + "build:standalone": "cd packages/react-devtools-core && yarn run build", + "deploy": "yarn run deploy:demo && yarn run deploy:chrome && yarn run deploy:firefox", + "deploy:demo": "yarn run build:demo && cd shells/dev/ && now deploy && now alias react-devtools-experimental", + "deploy:chrome": "node ./shells/browser/chrome/deploy", + "deploy:firefox": "node ./shells/browser/firefox/deploy", + "linc": "lint-staged", + "lint": "eslint '**/*.js'", + "lint:ci": "eslint '**/*.js' --max-warnings 0", + "precommit": "lint-staged", + "prettier": "prettier --write '**/*.{js,json,css}'", + "prettier:ci": "prettier --check '**/*.{js,json,css}'", + "start": "cd ./shells/dev && cross-env NODE_ENV=development cross-env TARGET=local webpack-dev-server --open", + "start:core:backend": "cd ./packages/react-devtools-core && yarn start:backend", + "start:core:standalone": "cd ./packages/react-devtools-core && yarn start:standalone", + "start:electron": "cd ./packages/react-devtools && node bin.js", + "start:prod": "cd ./shells/dev && cross-env NODE_ENV=production cross-env TARGET=local webpack-dev-server --open", + "test": "jest", + "test-debug": "node --inspect-brk node_modules/.bin/jest --runInBand", + "test:chrome": "node ./shells/browser/chrome/test", + "test:firefox": "node ./shells/browser/firefox/test", + "test:standalone": "cd packages/react-devtools && yarn start", + "typecheck": "flow check --show-all-errors" + }, + "devEngines": { + "node": "10.x || 11.x" + }, + "lint-staged": { + "{shells,src}/**/*.{js,json,css}": [ + "prettier --write", + "git add" + ], + "**/*.js": "eslint --max-warnings 0" + }, + "dependencies": { + "@babel/core": "^7.1.6", + "@babel/plugin-proposal-class-properties": "^7.1.0", + "@babel/plugin-transform-flow-strip-types": "^7.1.6", + "@babel/plugin-transform-react-jsx-source": "^7.2.0", + "@babel/preset-env": "^7.1.6", + "@babel/preset-flow": "^7.0.0", + "@babel/preset-react": "^7.0.0", + "@reach/menu-button": "^0.1.17", + "@reach/tooltip": "^0.2.2", + "archiver": "^3.0.0", + "babel-core": "^7.0.0-bridge", + "babel-eslint": "^9.0.0", + "babel-jest": "^24.7.1", + "babel-loader": "^8.0.4", + "child-process-promise": "^2.2.1", + "chrome-launch": "^1.1.4", + "classnames": "2.2.1", + "cli-spinners": "^1.0.0", + "clipboard-js": "^0.3.6", + "cross-env": "^5.2.0", + "crx": "^5.0.0", + "css-loader": "^1.0.1", + "error-stack-parser": "^2.0.2", + "es6-symbol": "3.0.2", + "escape-string-regexp": "^1.0.5", + "eslint": "^4.19.1", + "eslint-config-prettier": "^2.9.0", + "eslint-config-react-app": "^2.1.0", + "eslint-config-standard": "^11.0.0", + "eslint-config-standard-react": "^6.0.0", + "eslint-plugin-flowtype": "^2.47.1", + "eslint-plugin-import": "^2.11.0", + "eslint-plugin-jsx-a11y": "^5", + "eslint-plugin-node": "^6.0.1", + "eslint-plugin-prettier": "^2.6.0", + "eslint-plugin-promise": "^3.7.0", + "eslint-plugin-react": "^7.7.0", + "eslint-plugin-react-hooks": "^1.6.0", + "eslint-plugin-standard": "^3.0.1", + "events": "^3.0.0", + "fbjs": "0.5.1", + "fbjs-scripts": "0.7.0", + "firefox-profile": "^1.0.2", + "flow-bin": "^0.103.0", + "fs-extra": "^3.0.1", + "gh-pages": "^1.0.0", + "immutable": "3.7.6", + "jest": "^24.7.1", + "lerna": "^2.8.0", + "lint-staged": "^7.0.5", + "local-storage-fallback": "^4.1.1", + "lodash.throttle": "^4.1.1", + "log-update": "^2.0.0", + "lru-cache": "^4.1.3", + "memoize-one": "^3.1.1", + "node-libs-browser": "0.5.3", + "nullthrows": "^1.0.0", + "object-assign": "4.0.1", + "opener": "^1.5.1", + "prettier": "^1.16.4", + "prop-types": "^15.6.2", + "raw-loader": "^3.1.0", + "react": "^0.0.0-424099da6", + "react-15": "npm:react@^15", + "react-color": "^2.11.7", + "react-dom": "^0.0.0-424099da6", + "react-dom-15": "npm:react-dom@^15", + "react-is": "0.0.0-424099da6", + "react-native-web": "^0.11.5", + "react-test-renderer": "^0.0.0-424099da6", + "react-virtualized-auto-sizer": "^1.0.2", + "request-promise": "^4.2.4", + "rimraf": "^2.6.3", + "scheduler": "^0.0.0-424099da6", + "semver": "^5.5.1", + "serve-static": "^1.14.1", + "style-loader": "^0.23.1", + "web-ext": "^3.0.0", + "webpack": "^4.26.0", + "webpack-cli": "^3.1.2", + "webpack-dev-server": "^3.3.1", + "yargs": "^13.2.4" + } +} diff --git a/extension/shells/browser/chrome/README.md b/extension/shells/browser/chrome/README.md new file mode 100644 index 0000000000000..dd77d072c8282 --- /dev/null +++ b/extension/shells/browser/chrome/README.md @@ -0,0 +1,12 @@ +# The Chrome extension + +The source code for this extension has moved to `shells/webextension`. + +Modify the source code there and then rebuild this extension by running `node build` from this directory or `yarn run build:extension:chrome` from the root directory. + +## Testing in Chrome + +You can test a local build of the web extension like so: + + 1. Build the extension: `node build` + 1. Follow the on-screen instructions. diff --git a/extension/shells/browser/chrome/build.js b/extension/shells/browser/chrome/build.js new file mode 100644 index 0000000000000..8bcb863f11c51 --- /dev/null +++ b/extension/shells/browser/chrome/build.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node + +const chalk = require('chalk'); +const { execSync } = require('child_process'); +const { existsSync } = require('fs'); +const { isAbsolute, join, relative } = require('path'); +const { argv } = require('yargs'); +const build = require('../shared/build'); + +const main = async () => { + const { crx, keyPath } = argv; + + if (crx) { + if (!keyPath || !existsSync(keyPath)) { + console.error('Must specify a key file (.pem) to build CRX'); + process.exit(1); + } + } + + await build('chrome'); + + if (crx) { + const cwd = join(__dirname, 'build'); + + let safeKeyPath = keyPath; + if (!isAbsolute(keyPath)) { + safeKeyPath = join(relative(cwd, process.cwd()), keyPath); + } + + execSync(`crx pack ./unpacked -o ReactDevTools.crx -p ${safeKeyPath}`, { + cwd, + }); + } + + console.log(chalk.green('\nThe Chrome extension has been built!')); + console.log(chalk.green('You can test this build by running:')); + console.log(chalk.gray('\n# From the react-devtools root directory:')); + console.log('yarn run test:chrome'); +}; + +main(); diff --git a/extension/shells/browser/chrome/deploy.js b/extension/shells/browser/chrome/deploy.js new file mode 100644 index 0000000000000..1cfdf39085e34 --- /dev/null +++ b/extension/shells/browser/chrome/deploy.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +const deploy = require('../shared/deploy'); + +const main = async () => await deploy('chrome'); + +main(); diff --git a/extension/shells/browser/chrome/manifest.json b/extension/shells/browser/chrome/manifest.json new file mode 100644 index 0000000000000..3a1dfb1db4bdf --- /dev/null +++ b/extension/shells/browser/chrome/manifest.json @@ -0,0 +1,60 @@ +{ + "manifest_version": 2, + "name": "React Developer Tools", + "description": "Adds React debugging tools to the Chrome Developer Tools.", + "version": "4.0.0", + "version_name": "4.0.0", + + "minimum_chrome_version": "49", + + "icons": { + "16": "icons/16-production.png", + "32": "icons/32-production.png", + "48": "icons/48-production.png", + "128": "icons/128-production.png" + }, + + "browser_action": { + "default_icon": { + "16": "icons/16-disabled.png", + "32": "icons/32-disabled.png", + "48": "icons/48-disabled.png", + "128": "icons/128-disabled.png" + }, + + "default_popup": "popups/disabled.html" + }, + + "devtools_page": "main.html", + + "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", + "web_accessible_resources": [ + "main.html", + "panel.html", + "build/backend.js", + "build/renderer.js" + ], + + "background": { + "scripts": ["build/background.js"], + "persistent": false + }, + + "permissions": [ + "", + "background", + "tabs", + "webNavigation", + "file:///*", + "http://*/*", + "https://*/*" + ], + + "content_scripts": [ + { + "matches": [""], + "js": ["build/injectGlobalHook.js"], + "run_at": "document_start" + } + ] +} diff --git a/extension/shells/browser/chrome/now.json b/extension/shells/browser/chrome/now.json new file mode 100644 index 0000000000000..e541ec866d001 --- /dev/null +++ b/extension/shells/browser/chrome/now.json @@ -0,0 +1,5 @@ +{ + "name": "react-devtools-experimental-chrome", + "alias": ["react-devtools-experimental-chrome"], + "files": ["index.html", "ReactDevTools.zip"] +} diff --git a/extension/shells/browser/chrome/test.js b/extension/shells/browser/chrome/test.js new file mode 100644 index 0000000000000..c010a3c319dde --- /dev/null +++ b/extension/shells/browser/chrome/test.js @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +const chromeLaunch = require('chrome-launch'); // eslint-disable-line import/no-extraneous-dependencies +const { resolve } = require('path'); + +const EXTENSION_PATH = resolve('shells/browser/chrome/build/unpacked'); +const START_URL = 'https://facebook.github.io/react/'; + +chromeLaunch(START_URL, { + args: [`--load-extension=${EXTENSION_PATH}`], +}); diff --git a/extension/shells/browser/firefox/README.md b/extension/shells/browser/firefox/README.md new file mode 100644 index 0000000000000..c4bbc8d2de6c6 --- /dev/null +++ b/extension/shells/browser/firefox/README.md @@ -0,0 +1,12 @@ +# The Firefox extension + +The source code for this extension has moved to `shells/webextension`. + +Modify the source code there and then rebuild this extension by running `node build` from this directory or `yarn run build:extension:firefox` from the root directory. + +## Testing in Firefox + + 1. Build the extension: `node build` + 1. Follow the on-screen instructions. + +You can test upcoming releases of Firefox by downloading the Beta or Nightly build from the [Firefox releases](https://www.mozilla.org/en-US/firefox/channel/desktop/) page and then following the on-screen instructions after building. diff --git a/extension/shells/browser/firefox/build.js b/extension/shells/browser/firefox/build.js new file mode 100644 index 0000000000000..5f7e7c5df95c1 --- /dev/null +++ b/extension/shells/browser/firefox/build.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +const chalk = require('chalk'); +const build = require('../shared/build'); + +const main = async () => { + await build('firefox'); + + console.log(chalk.green('\nThe Firefox extension has been built!')); + console.log(chalk.green('You can test this build by running:')); + console.log(chalk.gray('\n# From the react-devtools root directory:')); + console.log('yarn run test:firefox'); + console.log( + chalk.gray('\n# You can also test against upcoming Firefox releases.') + ); + console.log( + chalk.gray( + '# First download a release from https://www.mozilla.org/en-US/firefox/channel/desktop/' + ) + ); + console.log( + chalk.gray( + '# And then tell web-ext which release to use (eg firefoxdeveloperedition, nightly, beta):' + ) + ); + console.log('WEB_EXT_FIREFOX=nightly yarn run test:firefox'); + console.log(chalk.gray('\n# You can test against older versions too:')); + console.log( + 'WEB_EXT_FIREFOX=/Applications/Firefox52.app/Contents/MacOS/firefox-bin yarn run test:firefox' + ); +}; + +main(); + +module.exports = { main }; diff --git a/extension/shells/browser/firefox/deploy.js b/extension/shells/browser/firefox/deploy.js new file mode 100644 index 0000000000000..b7977d62f0abc --- /dev/null +++ b/extension/shells/browser/firefox/deploy.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +const deploy = require('../shared/deploy'); + +const main = async () => await deploy('firefox'); + +main(); diff --git a/extension/shells/browser/firefox/manifest.json b/extension/shells/browser/firefox/manifest.json new file mode 100644 index 0000000000000..02f24a0fef864 --- /dev/null +++ b/extension/shells/browser/firefox/manifest.json @@ -0,0 +1,64 @@ +{ + "manifest_version": 2, + "name": "React Developer Tools", + "description": "Adds React debugging tools to the Firefox Developer Tools.", + "version": "4.0.0", + + "applications": { + "gecko": { + "id": "@react-devtools", + "strict_min_version": "54.0" + } + }, + + "icons": { + "16": "icons/16-production.png", + "32": "icons/32-production.png", + "48": "icons/48-production.png", + "128": "icons/128-production.png" + }, + + "browser_action": { + "default_icon": { + "16": "icons/16-disabled.png", + "32": "icons/32-disabled.png", + "48": "icons/48-disabled.png", + "128": "icons/128-disabled.png" + }, + + "default_popup": "popups/disabled.html", + "browser_style": true + }, + + "devtools_page": "main.html", + + "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", + "web_accessible_resources": [ + "main.html", + "panel.html", + "build/backend.js", + "build/renderer.js" + ], + + "background": { + "scripts": ["build/background.js"] + }, + + "permissions": [ + "", + "activeTab", + "tabs", + "webNavigation", + "file:///*", + "http://*/*", + "https://*/*" + ], + + "content_scripts": [ + { + "matches": [""], + "js": ["build/injectGlobalHook.js"], + "run_at": "document_start" + } + ] +} diff --git a/extension/shells/browser/firefox/now.json b/extension/shells/browser/firefox/now.json new file mode 100644 index 0000000000000..5e61bb442f567 --- /dev/null +++ b/extension/shells/browser/firefox/now.json @@ -0,0 +1,5 @@ +{ + "name": "react-devtools-experimental-firefox", + "alias": ["react-devtools-experimental-firefox"], + "files": ["index.html", "ReactDevTools.zip"] +} diff --git a/extension/shells/browser/firefox/test.js b/extension/shells/browser/firefox/test.js new file mode 100644 index 0000000000000..7dd8d1e65b633 --- /dev/null +++ b/extension/shells/browser/firefox/test.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +const { exec } = require('child-process-promise'); +const { Finder } = require('firefox-profile'); +const { resolve } = require('path'); + +const EXTENSION_PATH = resolve('shells/browser/firefox/build/unpacked'); +const START_URL = 'https://facebook.github.io/react/'; + +const main = async () => { + const finder = new Finder(); + + // Use default Firefox profile for testing purposes. + // This prevents users from having to re-login-to sites before testing. + const findPathPromise = new Promise((resolvePromise, rejectPromise) => { + finder.getPath('default', (error, profile) => { + if (error) { + rejectPromise(error); + } else { + resolvePromise(profile); + } + }); + }); + + const options = [ + `--source-dir=${EXTENSION_PATH}`, + `--start-url=${START_URL}`, + '--browser-console', + ]; + + try { + const path = await findPathPromise; + const trimmedPath = path.replace(' ', '\\ '); + options.push(`--firefox-profile=${trimmedPath}`); + } catch (err) { + console.warn('Could not find default profile, using temporary profile.'); + } + + try { + await exec(`web-ext run ${options.join(' ')}`); + } catch (err) { + console.error('`web-ext run` failed', err.stdout, err.stderr); + } +}; + +main(); diff --git a/extension/shells/browser/shared/build.js b/extension/shells/browser/shared/build.js new file mode 100644 index 0000000000000..6d2ec5371f3bc --- /dev/null +++ b/extension/shells/browser/shared/build.js @@ -0,0 +1,120 @@ +#!/usr/bin/env node + +const archiver = require('archiver'); +const { execSync } = require('child_process'); +const { readFileSync, writeFileSync, createWriteStream } = require('fs'); +const { copy, ensureDir, move, remove } = require('fs-extra'); +const { join } = require('path'); +const { getGitCommit } = require('../../utils'); + +// These files are copied along with Webpack-bundled files +// to produce the final web extension +const STATIC_FILES = ['icons', 'popups', 'main.html', 'panel.html']; + +const preProcess = async (destinationPath, tempPath) => { + await remove(destinationPath); // Clean up from previously completed builds + await remove(tempPath); // Clean up from any previously failed builds + await ensureDir(tempPath); // Create temp dir for this new build +}; + +const build = async (tempPath, manifestPath) => { + const binPath = join(tempPath, 'bin'); + const zipPath = join(tempPath, 'zip'); + + const webpackPath = join( + __dirname, + '..', + '..', + '..', + 'node_modules', + '.bin', + 'webpack' + ); + execSync( + `${webpackPath} --config webpack.config.js --output-path ${binPath}`, + { + cwd: __dirname, + env: process.env, + stdio: 'inherit', + } + ); + execSync( + `${webpackPath} --config webpack.backend.js --output-path ${binPath}`, + { + cwd: __dirname, + env: process.env, + stdio: 'inherit', + } + ); + + // Make temp dir + await ensureDir(zipPath); + + const copiedManifestPath = join(zipPath, 'manifest.json'); + + // Copy unbuilt source files to zip dir to be packaged: + await copy(binPath, join(zipPath, 'build')); + await copy(manifestPath, copiedManifestPath); + await Promise.all( + STATIC_FILES.map(file => copy(join(__dirname, file), join(zipPath, file))) + ); + + const commit = getGitCommit(); + const versionDateString = `${commit} (${new Date().toLocaleDateString()})`; + + const manifest = JSON.parse(readFileSync(copiedManifestPath).toString()); + if (manifest.version_name) { + manifest.version_name = versionDateString; + } else { + manifest.description += `\n\nCreated from revision ${versionDateString}`; + } + + writeFileSync(copiedManifestPath, JSON.stringify(manifest, null, 2)); + + // Pack the extension + const archive = archiver('zip', { zlib: { level: 9 } }); + const zipStream = createWriteStream(join(tempPath, 'ReactDevTools.zip')); + await new Promise((resolve, reject) => { + archive + .directory(zipPath, false) + .on('error', err => reject(err)) + .pipe(zipStream); + archive.finalize(); + zipStream.on('close', () => resolve()); + }); +}; + +const postProcess = async (tempPath, destinationPath) => { + const unpackedSourcePath = join(tempPath, 'zip'); + const packedSourcePath = join(tempPath, 'ReactDevTools.zip'); + const packedDestPath = join(destinationPath, 'ReactDevTools.zip'); + const unpackedDestPath = join(destinationPath, 'unpacked'); + + await move(unpackedSourcePath, unpackedDestPath); // Copy built files to destination + await move(packedSourcePath, packedDestPath); // Copy built files to destination + await remove(tempPath); // Clean up temp directory and files +}; + +const main = async buildId => { + const root = join(__dirname, '..', buildId); + const manifestPath = join(root, 'manifest.json'); + const destinationPath = join(root, 'build'); + + try { + const tempPath = join(__dirname, 'build', buildId); + await preProcess(destinationPath, tempPath); + await build(tempPath, manifestPath); + + const builtUnpackedPath = join(destinationPath, 'unpacked'); + await postProcess(tempPath, destinationPath); + + return builtUnpackedPath; + } catch (error) { + console.error(error); + process.exit(1); + } + + return null; +}; + +module.exports = main; diff --git a/extension/shells/browser/shared/deploy.chrome.html b/extension/shells/browser/shared/deploy.chrome.html new file mode 100644 index 0000000000000..eb70be0024e98 --- /dev/null +++ b/extension/shells/browser/shared/deploy.chrome.html @@ -0,0 +1,8 @@ +
    +
  1. download extension
  2. +
  3. Double-click to extract
  4. +
  5. Navigate to chrome://extensions/
  6. +
  7. Enable "Developer mode"
  8. +
  9. Click "LOAD UNPACKED"
  10. +
  11. Select extracted extension folder (ReactDevTools)
  12. +
\ No newline at end of file diff --git a/extension/shells/browser/shared/deploy.firefox.html b/extension/shells/browser/shared/deploy.firefox.html new file mode 100644 index 0000000000000..d2879a17c4932 --- /dev/null +++ b/extension/shells/browser/shared/deploy.firefox.html @@ -0,0 +1,7 @@ +
    +
  1. download extension
  2. +
  3. Extract/unzip
  4. +
  5. Visit about:debugging
  6. +
  7. Click "Load Temporary Add-on"
  8. +
  9. Select the manifest.json file inside of the extracted extension folder (ReactDevTools)
  10. +
\ No newline at end of file diff --git a/extension/shells/browser/shared/deploy.html b/extension/shells/browser/shared/deploy.html new file mode 100644 index 0000000000000..fa9a21b42fd12 --- /dev/null +++ b/extension/shells/browser/shared/deploy.html @@ -0,0 +1,46 @@ + + + + React DevTools pre-release + + + + +

+ React DevTools pre-release +

+ +

+ Created on %date% from + %commit% +

+ +

+ This is a preview build of an unreleased DevTools extension. + It has no developer support. +

+ +

Installation instructions

+ %installation% +

+ If you already have the React DevTools extension installed, you will need to temporarily disable or remove it in order to install this prerelease build. +

+ +

Bug reports

+

+ Please report bugs as GitHub issues. + Please include all of the info required to reproduce the bug (e.g. links, code, instructions). +

+ +

Feature requests

+

+ Feature requests are not being accepted at this time. +

+ + diff --git a/extension/shells/browser/shared/deploy.js b/extension/shells/browser/shared/deploy.js new file mode 100644 index 0000000000000..6cc33e1cbf4e8 --- /dev/null +++ b/extension/shells/browser/shared/deploy.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +const { exec, execSync } = require('child_process'); +const { readFileSync, writeFileSync } = require('fs'); +const { join } = require('path'); + +const main = async buildId => { + const root = join(__dirname, '..', buildId); + const buildPath = join(root, 'build'); + + execSync(`node ${join(root, './build')}`, { + cwd: __dirname, + env: { + ...process.env, + NODE_ENV: 'production', + }, + stdio: 'inherit', + }); + + await exec(`cp ${join(root, 'now.json')} ${join(buildPath, 'now.json')}`, { + cwd: root, + }); + + const file = readFileSync(join(root, 'now.json')); + const json = JSON.parse(file); + const alias = json.alias[0]; + + const commit = execSync('git rev-parse HEAD') + .toString() + .trim() + .substr(0, 7); + + let date = new Date(); + date = `${date.toLocaleDateString()} – ${date.toLocaleTimeString()}`; + + const installationInstructions = + buildId === 'chrome' + ? readFileSync(join(__dirname, 'deploy.chrome.html')) + : readFileSync(join(__dirname, 'deploy.firefox.html')); + + let html = readFileSync(join(__dirname, 'deploy.html')).toString(); + html = html.replace(/%commit%/g, commit); + html = html.replace(/%date%/g, date); + html = html.replace(/%installation%/, installationInstructions); + + writeFileSync(join(buildPath, 'index.html'), html); + + await exec(`now deploy && now alias ${alias}`, { + cwd: buildPath, + stdio: 'inherit', + }); + + console.log(`Deployed to https://${alias}.now.sh`); +}; + +module.exports = main; diff --git a/extension/shells/browser/shared/icons/128-deadcode.png b/extension/shells/browser/shared/icons/128-deadcode.png new file mode 100644 index 0000000000000..b6ecc88e13ac0 Binary files /dev/null and b/extension/shells/browser/shared/icons/128-deadcode.png differ diff --git a/extension/shells/browser/shared/icons/128-development.png b/extension/shells/browser/shared/icons/128-development.png new file mode 100644 index 0000000000000..b6ecc88e13ac0 Binary files /dev/null and b/extension/shells/browser/shared/icons/128-development.png differ diff --git a/extension/shells/browser/shared/icons/128-disabled.png b/extension/shells/browser/shared/icons/128-disabled.png new file mode 100644 index 0000000000000..67b1c9a31a6df Binary files /dev/null and b/extension/shells/browser/shared/icons/128-disabled.png differ diff --git a/extension/shells/browser/shared/icons/128-outdated.png b/extension/shells/browser/shared/icons/128-outdated.png new file mode 100644 index 0000000000000..05792b762878c Binary files /dev/null and b/extension/shells/browser/shared/icons/128-outdated.png differ diff --git a/extension/shells/browser/shared/icons/128-production.png b/extension/shells/browser/shared/icons/128-production.png new file mode 100644 index 0000000000000..b9327946441f5 Binary files /dev/null and b/extension/shells/browser/shared/icons/128-production.png differ diff --git a/extension/shells/browser/shared/icons/128-unminified.png b/extension/shells/browser/shared/icons/128-unminified.png new file mode 100644 index 0000000000000..b6ecc88e13ac0 Binary files /dev/null and b/extension/shells/browser/shared/icons/128-unminified.png differ diff --git a/extension/shells/browser/shared/icons/16-deadcode.png b/extension/shells/browser/shared/icons/16-deadcode.png new file mode 100644 index 0000000000000..33d99798e07b2 Binary files /dev/null and b/extension/shells/browser/shared/icons/16-deadcode.png differ diff --git a/extension/shells/browser/shared/icons/16-development.png b/extension/shells/browser/shared/icons/16-development.png new file mode 100644 index 0000000000000..33d99798e07b2 Binary files /dev/null and b/extension/shells/browser/shared/icons/16-development.png differ diff --git a/extension/shells/browser/shared/icons/16-disabled.png b/extension/shells/browser/shared/icons/16-disabled.png new file mode 100644 index 0000000000000..2f0317ea3fd6b Binary files /dev/null and b/extension/shells/browser/shared/icons/16-disabled.png differ diff --git a/extension/shells/browser/shared/icons/16-outdated.png b/extension/shells/browser/shared/icons/16-outdated.png new file mode 100644 index 0000000000000..aa42bfe0b4e0c Binary files /dev/null and b/extension/shells/browser/shared/icons/16-outdated.png differ diff --git a/extension/shells/browser/shared/icons/16-production.png b/extension/shells/browser/shared/icons/16-production.png new file mode 100644 index 0000000000000..1c253dca5d56d Binary files /dev/null and b/extension/shells/browser/shared/icons/16-production.png differ diff --git a/extension/shells/browser/shared/icons/16-unminified.png b/extension/shells/browser/shared/icons/16-unminified.png new file mode 100644 index 0000000000000..33d99798e07b2 Binary files /dev/null and b/extension/shells/browser/shared/icons/16-unminified.png differ diff --git a/extension/shells/browser/shared/icons/32-deadcode.png b/extension/shells/browser/shared/icons/32-deadcode.png new file mode 100644 index 0000000000000..c4a6bda3e6a6d Binary files /dev/null and b/extension/shells/browser/shared/icons/32-deadcode.png differ diff --git a/extension/shells/browser/shared/icons/32-development.png b/extension/shells/browser/shared/icons/32-development.png new file mode 100644 index 0000000000000..c4a6bda3e6a6d Binary files /dev/null and b/extension/shells/browser/shared/icons/32-development.png differ diff --git a/extension/shells/browser/shared/icons/32-disabled.png b/extension/shells/browser/shared/icons/32-disabled.png new file mode 100644 index 0000000000000..7c75045323888 Binary files /dev/null and b/extension/shells/browser/shared/icons/32-disabled.png differ diff --git a/extension/shells/browser/shared/icons/32-outdated.png b/extension/shells/browser/shared/icons/32-outdated.png new file mode 100644 index 0000000000000..6eae901bf0588 Binary files /dev/null and b/extension/shells/browser/shared/icons/32-outdated.png differ diff --git a/extension/shells/browser/shared/icons/32-production.png b/extension/shells/browser/shared/icons/32-production.png new file mode 100644 index 0000000000000..9192719e5b50f Binary files /dev/null and b/extension/shells/browser/shared/icons/32-production.png differ diff --git a/extension/shells/browser/shared/icons/32-unminified.png b/extension/shells/browser/shared/icons/32-unminified.png new file mode 100644 index 0000000000000..c4a6bda3e6a6d Binary files /dev/null and b/extension/shells/browser/shared/icons/32-unminified.png differ diff --git a/extension/shells/browser/shared/icons/48-deadcode.png b/extension/shells/browser/shared/icons/48-deadcode.png new file mode 100644 index 0000000000000..f3224021a560c Binary files /dev/null and b/extension/shells/browser/shared/icons/48-deadcode.png differ diff --git a/extension/shells/browser/shared/icons/48-development.png b/extension/shells/browser/shared/icons/48-development.png new file mode 100644 index 0000000000000..f3224021a560c Binary files /dev/null and b/extension/shells/browser/shared/icons/48-development.png differ diff --git a/extension/shells/browser/shared/icons/48-disabled.png b/extension/shells/browser/shared/icons/48-disabled.png new file mode 100644 index 0000000000000..372b6e00e88a8 Binary files /dev/null and b/extension/shells/browser/shared/icons/48-disabled.png differ diff --git a/extension/shells/browser/shared/icons/48-outdated.png b/extension/shells/browser/shared/icons/48-outdated.png new file mode 100644 index 0000000000000..342fedea1ed50 Binary files /dev/null and b/extension/shells/browser/shared/icons/48-outdated.png differ diff --git a/extension/shells/browser/shared/icons/48-production.png b/extension/shells/browser/shared/icons/48-production.png new file mode 100644 index 0000000000000..9aac93a622bfa Binary files /dev/null and b/extension/shells/browser/shared/icons/48-production.png differ diff --git a/extension/shells/browser/shared/icons/48-unminified.png b/extension/shells/browser/shared/icons/48-unminified.png new file mode 100644 index 0000000000000..f3224021a560c Binary files /dev/null and b/extension/shells/browser/shared/icons/48-unminified.png differ diff --git a/extension/shells/browser/shared/icons/deadcode.svg b/extension/shells/browser/shared/icons/deadcode.svg new file mode 100644 index 0000000000000..ccd6e669061f7 --- /dev/null +++ b/extension/shells/browser/shared/icons/deadcode.svg @@ -0,0 +1 @@ +development780780 \ No newline at end of file diff --git a/extension/shells/browser/shared/icons/development.svg b/extension/shells/browser/shared/icons/development.svg new file mode 100644 index 0000000000000..ccd6e669061f7 --- /dev/null +++ b/extension/shells/browser/shared/icons/development.svg @@ -0,0 +1 @@ +development780780 \ No newline at end of file diff --git a/extension/shells/browser/shared/icons/disabled.svg b/extension/shells/browser/shared/icons/disabled.svg new file mode 100644 index 0000000000000..73c2bb51cdbbc --- /dev/null +++ b/extension/shells/browser/shared/icons/disabled.svg @@ -0,0 +1 @@ +disabled \ No newline at end of file diff --git a/extension/shells/browser/shared/icons/outdated.svg b/extension/shells/browser/shared/icons/outdated.svg new file mode 100644 index 0000000000000..03b83c1eb559b --- /dev/null +++ b/extension/shells/browser/shared/icons/outdated.svg @@ -0,0 +1 @@ +outdated \ No newline at end of file diff --git a/extension/shells/browser/shared/icons/production.svg b/extension/shells/browser/shared/icons/production.svg new file mode 100644 index 0000000000000..1e974f5131012 --- /dev/null +++ b/extension/shells/browser/shared/icons/production.svg @@ -0,0 +1 @@ +production \ No newline at end of file diff --git a/extension/shells/browser/shared/main.html b/extension/shells/browser/shared/main.html new file mode 100644 index 0000000000000..f1c96d4a7a094 --- /dev/null +++ b/extension/shells/browser/shared/main.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/extension/shells/browser/shared/panel.html b/extension/shells/browser/shared/panel.html new file mode 100644 index 0000000000000..60fd1bdf13ff2 --- /dev/null +++ b/extension/shells/browser/shared/panel.html @@ -0,0 +1,32 @@ + + + + + + + + +
Unable to find React on the page.
+ + + diff --git a/extension/shells/browser/shared/popups/deadcode.html b/extension/shells/browser/shared/popups/deadcode.html new file mode 100644 index 0000000000000..5e74dc06c405f --- /dev/null +++ b/extension/shells/browser/shared/popups/deadcode.html @@ -0,0 +1,32 @@ + + +

+ This page includes an extra development build of React. 🚧 +

+

+ The React build on this page includes both development and production versions because dead code elimination has not been applied correctly. +
+
+ This makes its size larger, and causes React to run slower. +
+
+ Make sure to set up dead code elimination before deployment. +

+
+

+ Open the developer tools, and the React tab will appear to the right. +

diff --git a/extension/shells/browser/shared/popups/development.html b/extension/shells/browser/shared/popups/development.html new file mode 100644 index 0000000000000..9c2089cc2f37c --- /dev/null +++ b/extension/shells/browser/shared/popups/development.html @@ -0,0 +1,28 @@ + + +

+ This page is using the development build of React. 🚧 +

+

+ Note that the development build is not suitable for production. +
+ Make sure to use the production build before deployment. +

+
+

+ Open the developer tools, and the React tab will appear to the right. +

diff --git a/extension/shells/browser/shared/popups/disabled.html b/extension/shells/browser/shared/popups/disabled.html new file mode 100644 index 0000000000000..a89b178d49cf8 --- /dev/null +++ b/extension/shells/browser/shared/popups/disabled.html @@ -0,0 +1,21 @@ + + +

+ This page doesn’t appear to be using React. +
+ If this seems wrong, follow the troubleshooting instructions. +

diff --git a/extension/shells/browser/shared/popups/outdated.html b/extension/shells/browser/shared/popups/outdated.html new file mode 100644 index 0000000000000..a6ec12bcafc1d --- /dev/null +++ b/extension/shells/browser/shared/popups/outdated.html @@ -0,0 +1,29 @@ + + +

+ This page is using an outdated version of React. ⌛ +

+

+ We recommend updating React to ensure that you receive important bugfixes and performance improvements. +
+
+ You can find the upgrade instructions on the React blog. +

+
+

+ Open the developer tools, and the React tab will appear to the right. +

diff --git a/extension/shells/browser/shared/popups/production.html b/extension/shells/browser/shared/popups/production.html new file mode 100644 index 0000000000000..1b65eb5b219b6 --- /dev/null +++ b/extension/shells/browser/shared/popups/production.html @@ -0,0 +1,21 @@ + + +

+ This page is using the production build of React. ✅ +
+ Open the developer tools, and the React tab will appear to the right. +

diff --git a/extension/shells/browser/shared/popups/shared.js b/extension/shells/browser/shared/popups/shared.js new file mode 100644 index 0000000000000..130f1f457ecf5 --- /dev/null +++ b/extension/shells/browser/shared/popups/shared.js @@ -0,0 +1,22 @@ +/* globals chrome */ + +document.addEventListener('DOMContentLoaded', function() { + // Make links work + const links = document.getElementsByTagName('a'); + for (let i = 0; i < links.length; i++) { + (function() { + const ln = links[i]; + const location = ln.href; + ln.onclick = function() { + chrome.tabs.create({ active: true, url: location }); + }; + })(); + } + + // Work around https://bugs.chromium.org/p/chromium/issues/detail?id=428044 + document.body.style.opacity = 0; + document.body.style.transition = 'opacity ease-out .4s'; + requestAnimationFrame(function() { + document.body.style.opacity = 1; + }); +}); diff --git a/extension/shells/browser/shared/popups/unminified.html b/extension/shells/browser/shared/popups/unminified.html new file mode 100644 index 0000000000000..553c9ac6acf55 --- /dev/null +++ b/extension/shells/browser/shared/popups/unminified.html @@ -0,0 +1,31 @@ + + +

+ This page is using an unminified build of React. 🚧 +

+

+ The React build on this page appears to be unminified. +
+ This makes its size larger, and causes React to run slower. +
+
+ Make sure to set up minification before deployment. +

+
+

+ Open the developer tools, and the React tab will appear to the right. +

diff --git a/extension/shells/browser/shared/src/backend.js b/extension/shells/browser/shared/src/backend.js new file mode 100644 index 0000000000000..c8cee150d8cb6 --- /dev/null +++ b/extension/shells/browser/shared/src/backend.js @@ -0,0 +1,77 @@ +// Do not use imports or top-level requires here! +// Running module factories is intentionally delayed until we know the hook exists. +// This is to avoid issues like: https://github.com/facebook/react-devtools/issues/1039 + +/** @flow */ + +function welcome(event) { + if ( + event.source !== window || + event.data.source !== 'react-devtools-content-script' + ) { + return; + } + + window.removeEventListener('message', welcome); + + setup(window.__REACT_DEVTOOLS_GLOBAL_HOOK__); +} + +window.addEventListener('message', welcome); + +function setup(hook) { + const Agent = require('src/backend/agent').default; + const Bridge = require('src/bridge').default; + const { initBackend } = require('src/backend'); + const setupNativeStyleEditor = require('src/backend/NativeStyleEditor/setupNativeStyleEditor') + .default; + + const bridge = new Bridge({ + listen(fn) { + const listener = event => { + if ( + event.source !== window || + !event.data || + event.data.source !== 'react-devtools-content-script' || + !event.data.payload + ) { + return; + } + fn(event.data.payload); + }; + window.addEventListener('message', listener); + return () => { + window.removeEventListener('message', listener); + }; + }, + send(event: string, payload: any, transferable?: Array) { + window.postMessage( + { + source: 'react-devtools-bridge', + payload: { event, payload }, + }, + '*', + transferable + ); + }, + }); + + const agent = new Agent(bridge); + agent.addListener('shutdown', () => { + // If we received 'shutdown' from `agent`, we assume the `bridge` is already shutting down, + // and that caused the 'shutdown' event on the `agent`, so we don't need to call `bridge.shutdown()` here. + hook.emit('shutdown'); + }); + + initBackend(hook, agent, window); + + // Setup React Native style editor if a renderer like react-native-web has injected it. + if (!!hook.resolveRNStyle) { + setupNativeStyleEditor( + bridge, + agent, + hook.resolveRNStyle, + hook.nativeStyleEditorValidAttributes + ); + } +} diff --git a/extension/shells/browser/shared/src/background.js b/extension/shells/browser/shared/src/background.js new file mode 100644 index 0000000000000..e22071b3aa657 --- /dev/null +++ b/extension/shells/browser/shared/src/background.js @@ -0,0 +1,113 @@ +/* global chrome */ + +const ports = {}; + +const IS_FIREFOX = navigator.userAgent.indexOf('Firefox') >= 0; + +chrome.runtime.onConnect.addListener(function(port) { + let tab = null; + let name = null; + if (isNumeric(port.name)) { + tab = port.name; + name = 'devtools'; + installContentScript(+port.name); + } else { + tab = port.sender.tab.id; + name = 'content-script'; + } + + if (!ports[tab]) { + ports[tab] = { + devtools: null, + 'content-script': null, + }; + } + ports[tab][name] = port; + + if (ports[tab].devtools && ports[tab]['content-script']) { + doublePipe(ports[tab].devtools, ports[tab]['content-script']); + } +}); + +function isNumeric(str: string): boolean { + return +str + '' === str; +} + +function installContentScript(tabId: number) { + chrome.tabs.executeScript( + tabId, + { file: '/build/contentScript.js' }, + function() {} + ); +} + +function doublePipe(one, two) { + one.onMessage.addListener(lOne); + function lOne(message) { + two.postMessage(message); + } + two.onMessage.addListener(lTwo); + function lTwo(message) { + one.postMessage(message); + } + function shutdown() { + one.onMessage.removeListener(lOne); + two.onMessage.removeListener(lTwo); + one.disconnect(); + two.disconnect(); + } + one.onDisconnect.addListener(shutdown); + two.onDisconnect.addListener(shutdown); +} + +function setIconAndPopup(reactBuildType, tabId) { + chrome.browserAction.setIcon({ + tabId: tabId, + path: { + '16': 'icons/16-' + reactBuildType + '.png', + '32': 'icons/32-' + reactBuildType + '.png', + '48': 'icons/48-' + reactBuildType + '.png', + '128': 'icons/128-' + reactBuildType + '.png', + }, + }); + chrome.browserAction.setPopup({ + tabId: tabId, + popup: 'popups/' + reactBuildType + '.html', + }); +} + +// Listen to URL changes on the active tab and reset the DevTools icon. +// This prevents non-disabled icons from sticking in Firefox. +// Don't listen to this event in Chrome though. +// It fires more frequently, often after onMessage() has been called. +if (IS_FIREFOX) { + chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (tab.active && changeInfo.status === 'loading') { + setIconAndPopup('disabled', tabId); + } + }); +} + +chrome.runtime.onMessage.addListener((request, sender) => { + if (sender.tab) { + // This is sent from the hook content script. + // It tells us a renderer has attached. + if (request.hasDetectedReact) { + // We use browserAction instead of pageAction because this lets us + // display a custom default popup when React is *not* detected. + // It is specified in the manifest. + let reactBuildType = request.reactBuildType; + if (sender.url.indexOf('facebook.github.io/react') !== -1) { + // Cheat: We use the development version on the website because + // it is better for interactive examples. However we're going + // to get misguided bug reports if the extension highlights it + // as using the dev version. We're just going to special case + // our own documentation and cheat. It is acceptable to use dev + // version of React in React docs, but not in any other case. + reactBuildType = 'production'; + } + + setIconAndPopup(reactBuildType, sender.tab.id); + } + } +}); diff --git a/extension/shells/browser/shared/src/contentScript.js b/extension/shells/browser/shared/src/contentScript.js new file mode 100644 index 0000000000000..68a44e21afcf5 --- /dev/null +++ b/extension/shells/browser/shared/src/contentScript.js @@ -0,0 +1,77 @@ +/* global chrome */ + +let backendDisconnected: boolean = false; +let backendInitialized: boolean = false; + +function sayHelloToBackend() { + window.postMessage( + { + source: 'react-devtools-content-script', + hello: true, + }, + '*' + ); +} + +function handleMessageFromDevtools(message) { + window.postMessage( + { + source: 'react-devtools-content-script', + payload: message, + }, + '*' + ); +} + +function handleMessageFromPage(evt) { + if ( + evt.source === window && + evt.data && + evt.data.source === 'react-devtools-bridge' + ) { + backendInitialized = true; + + port.postMessage(evt.data.payload); + } +} + +function handleDisconnect() { + backendDisconnected = true; + + window.removeEventListener('message', handleMessageFromPage); + + window.postMessage( + { + source: 'react-devtools-content-script', + payload: { + type: 'event', + event: 'shutdown', + }, + }, + '*' + ); +} + +// proxy from main page to devtools (via the background page) +var port = chrome.runtime.connect({ + name: 'content-script', +}); +port.onMessage.addListener(handleMessageFromDevtools); +port.onDisconnect.addListener(handleDisconnect); + +window.addEventListener('message', handleMessageFromPage); + +sayHelloToBackend(); + +// The backend waits to install the global hook until notified by the content script. +// In the event of a page reload, the content script might be loaded before the backend is injected. +// Because of this we need to poll the backend until it has been initialized. +if (!backendInitialized) { + const intervalID = setInterval(() => { + if (backendInitialized || backendDisconnected) { + clearInterval(intervalID); + } else { + sayHelloToBackend(); + } + }, 500); +} diff --git a/extension/shells/browser/shared/src/inject.js b/extension/shells/browser/shared/src/inject.js new file mode 100644 index 0000000000000..938e2cf7eb227 --- /dev/null +++ b/extension/shells/browser/shared/src/inject.js @@ -0,0 +1,24 @@ +/* global chrome */ + +export default function inject(scriptName: string, done: ?Function) { + const source = ` + // the prototype stuff is in case document.createElement has been modified + (function () { + var script = document.constructor.prototype.createElement.call(document, 'script'); + script.src = "${scriptName}"; + script.charset = "utf-8"; + document.documentElement.appendChild(script); + script.parentNode.removeChild(script); + })() + `; + + chrome.devtools.inspectedWindow.eval(source, function(response, error) { + if (error) { + console.log(error); + } + + if (typeof done === 'function') { + done(); + } + }); +} diff --git a/extension/shells/browser/shared/src/injectGlobalHook.js b/extension/shells/browser/shared/src/injectGlobalHook.js new file mode 100644 index 0000000000000..2bdc7c8a7f5b2 --- /dev/null +++ b/extension/shells/browser/shared/src/injectGlobalHook.js @@ -0,0 +1,89 @@ +/* global chrome */ + +import nullthrows from 'nullthrows'; +import { installHook } from 'src/hook'; +import { SESSION_STORAGE_RELOAD_AND_PROFILE_KEY } from 'src/constants'; +import { sessionStorageGetItem } from 'src/storage'; + +function injectCode(code) { + const script = document.createElement('script'); + script.textContent = code; + + // This script runs before the element is created, + // so we add the script to instead. + nullthrows(document.documentElement).appendChild(script); + nullthrows(script.parentNode).removeChild(script); +} + +let lastDetectionResult; + +// We want to detect when a renderer attaches, and notify the "background page" +// (which is shared between tabs and can highlight the React icon). +// Currently we are in "content script" context, so we can't listen to the hook directly +// (it will be injected directly into the page). +// So instead, the hook will use postMessage() to pass message to us here. +// And when this happens, we'll send a message to the "background page". +window.addEventListener('message', function(evt) { + if ( + evt.source === window && + evt.data && + evt.data.source === 'react-devtools-detector' + ) { + lastDetectionResult = { + hasDetectedReact: true, + reactBuildType: evt.data.reactBuildType, + }; + chrome.runtime.sendMessage(lastDetectionResult); + } +}); + +// NOTE: Firefox WebExtensions content scripts are still alive and not re-injected +// while navigating the history to a document that has not been destroyed yet, +// replay the last detection result if the content script is active and the +// document has been hidden and shown again. +window.addEventListener('pageshow', function(evt) { + if (!lastDetectionResult || evt.target !== window.document) { + return; + } + chrome.runtime.sendMessage(lastDetectionResult); +}); + +const detectReact = ` +window.__REACT_DEVTOOLS_GLOBAL_HOOK__.on('renderer', function(evt) { + window.postMessage({ + source: 'react-devtools-detector', + reactBuildType: evt.reactBuildType, + }, '*'); +}); +`; +const saveNativeValues = ` +window.__REACT_DEVTOOLS_GLOBAL_HOOK__.nativeObjectCreate = Object.create; +window.__REACT_DEVTOOLS_GLOBAL_HOOK__.nativeMap = Map; +window.__REACT_DEVTOOLS_GLOBAL_HOOK__.nativeWeakMap = WeakMap; +window.__REACT_DEVTOOLS_GLOBAL_HOOK__.nativeSet = Set; +`; + +// If we have just reloaded to profile, we need to inject the renderer interface before the app loads. +if (sessionStorageGetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY) === 'true') { + const rendererURL = chrome.runtime.getURL('build/renderer.js'); + let rendererCode; + + // We need to inject in time to catch the initial mount. + // This means we need to synchronously read the renderer code itself, + // and synchronously inject it into the page. + // There are very few ways to actually do this. + // This seems to be the best approach. + const request = new XMLHttpRequest(); + request.addEventListener('load', function() { + rendererCode = this.responseText; + }); + request.open('GET', rendererURL, false); + request.send(); + injectCode(rendererCode); +} + +// Inject a `__REACT_DEVTOOLS_GLOBAL_HOOK__` global so that React can detect that the +// devtools are installed (and skip its suggestion to install the devtools). +injectCode( + ';(' + installHook.toString() + '(window))' + saveNativeValues + detectReact +); diff --git a/extension/shells/browser/shared/src/main.js b/extension/shells/browser/shared/src/main.js new file mode 100644 index 0000000000000..f4942e5f01f80 --- /dev/null +++ b/extension/shells/browser/shared/src/main.js @@ -0,0 +1,316 @@ +/* global chrome */ + +import { createElement } from 'react'; +import { unstable_createRoot as createRoot, flushSync } from 'react-dom'; +import Bridge from 'src/bridge'; +import Store from 'src/devtools/store'; +import inject from './inject'; +import { + createViewElementSource, + getBrowserName, + getBrowserTheme, +} from './utils'; +import { getSavedComponentFilters, getAppendComponentStack } from 'src/utils'; +import { + localStorageGetItem, + localStorageRemoveItem, + localStorageSetItem, +} from 'src/storage'; +import DevTools from 'src/devtools/views/DevTools'; + +const LOCAL_STORAGE_SUPPORTS_PROFILING_KEY = + 'React::DevTools::supportsProfiling'; + +let panelCreated = false; + +// The renderer interface can't read saved component filters directly, +// because they are stored in localStorage within the context of the extension. +// Instead it relies on the extension to pass filters through. +function syncSavedPreferences() { + const componentFilters = getSavedComponentFilters(); + chrome.devtools.inspectedWindow.eval( + `window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = ${JSON.stringify( + componentFilters + )};` + ); + + const appendComponentStack = getAppendComponentStack(); + chrome.devtools.inspectedWindow.eval( + `window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ = ${JSON.stringify( + appendComponentStack + )};` + ); +} + +syncSavedPreferences(); + +function createPanelIfReactLoaded() { + if (panelCreated) { + return; + } + + chrome.devtools.inspectedWindow.eval( + 'window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.renderers.size > 0', + function(pageHasReact, error) { + if (!pageHasReact || panelCreated) { + return; + } + + panelCreated = true; + + clearInterval(loadCheckInterval); + + let bridge = null; + let store = null; + + let profilingData = null; + + let componentsPortalContainer = null; + let profilerPortalContainer = null; + + let cloneStyleTags = null; + let mostRecentOverrideTab = null; + let render = null; + let root = null; + + const tabId = chrome.devtools.inspectedWindow.tabId; + + function initBridgeAndStore() { + const port = chrome.runtime.connect({ + name: '' + tabId, + }); + // Looks like `port.onDisconnect` does not trigger on in-tab navigation like new URL or back/forward navigation, + // so it makes no sense to handle it here. + + bridge = new Bridge({ + listen(fn) { + const listener = message => fn(message); + // Store the reference so that we unsubscribe from the same object. + const portOnMessage = port.onMessage; + portOnMessage.addListener(listener); + return () => { + portOnMessage.removeListener(listener); + }; + }, + send(event: string, payload: any, transferable?: Array) { + port.postMessage({ event, payload }, transferable); + }, + }); + bridge.addListener('reloadAppForProfiling', () => { + localStorageSetItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY, 'true'); + chrome.devtools.inspectedWindow.eval('window.location.reload();'); + }); + bridge.addListener('syncSelectionToNativeElementsPanel', () => { + setBrowserSelectionFromReact(); + }); + + // This flag lets us tip the Store off early that we expect to be profiling. + // This avoids flashing a temporary "Profiling not supported" message in the Profiler tab, + // after a user has clicked the "reload and profile" button. + let isProfiling = false; + let supportsProfiling = false; + if ( + localStorageGetItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY) === 'true' + ) { + supportsProfiling = true; + isProfiling = true; + localStorageRemoveItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY); + } + + store = new Store(bridge, { + isProfiling, + supportsReloadAndProfile: getBrowserName() === 'Chrome', + supportsProfiling, + }); + store.profilerStore.profilingData = profilingData; + + // Initialize the backend only once the Store has been initialized. + // Otherwise the Store may miss important initial tree op codes. + inject(chrome.runtime.getURL('build/backend.js')); + + const viewElementSourceFunction = createViewElementSource( + bridge, + store + ); + + root = createRoot(document.createElement('div')); + + render = (overrideTab = mostRecentOverrideTab) => { + mostRecentOverrideTab = overrideTab; + + root.render( + createElement(DevTools, { + bridge, + browserTheme: getBrowserTheme(), + componentsPortalContainer, + overrideTab, + profilerPortalContainer, + showTabBar: false, + showWelcomeToTheNewDevToolsDialog: true, + store, + viewElementSourceFunction, + }) + ); + }; + + render(); + } + + cloneStyleTags = () => { + const linkTags = []; + for (let linkTag of document.getElementsByTagName('link')) { + if (linkTag.rel === 'stylesheet') { + const newLinkTag = document.createElement('link'); + for (let attribute of linkTag.attributes) { + newLinkTag.setAttribute(attribute.nodeName, attribute.nodeValue); + } + linkTags.push(newLinkTag); + } + } + return linkTags; + }; + + initBridgeAndStore(); + + function ensureInitialHTMLIsCleared(container) { + if (container._hasInitialHTMLBeenCleared) { + return; + } + container.innerHTML = ''; + container._hasInitialHTMLBeenCleared = true; + } + + function setBrowserSelectionFromReact() { + // This is currently only called on demand when you press "view DOM". + // In the future, if Chrome adds an inspect() that doesn't switch tabs, + // we could make this happen automatically when you select another component. + chrome.devtools.inspectedWindow.eval( + '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' + + '(inspect(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0), true) :' + + 'false', + (didSelectionChange, error) => { + if (error) { + console.error(error); + } + } + ); + } + + function setReactSelectionFromBrowser() { + // When the user chooses a different node in the browser Elements tab, + // copy it over to the hook object so that we can sync the selection. + chrome.devtools.inspectedWindow.eval( + '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' + + '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = $0, true) :' + + 'false', + (didSelectionChange, error) => { + if (error) { + console.error(error); + } else if (didSelectionChange) { + // Remember to sync the selection next time we show Components tab. + needsToSyncElementSelection = true; + } + } + ); + } + + setReactSelectionFromBrowser(); + chrome.devtools.panels.elements.onSelectionChanged.addListener(() => { + setReactSelectionFromBrowser(); + }); + + let currentPanel = null; + let needsToSyncElementSelection = false; + + chrome.devtools.panels.create('⚛ Components', '', 'panel.html', panel => { + panel.onShown.addListener(panel => { + if (needsToSyncElementSelection) { + needsToSyncElementSelection = false; + bridge.send('syncSelectionFromNativeElementsPanel'); + } + + if (currentPanel === panel) { + return; + } + + currentPanel = panel; + componentsPortalContainer = panel.container; + + if (componentsPortalContainer != null) { + ensureInitialHTMLIsCleared(componentsPortalContainer); + render('components'); + panel.injectStyles(cloneStyleTags); + } + }); + panel.onHidden.addListener(() => { + // TODO: Stop highlighting and stuff. + }); + }); + + chrome.devtools.panels.create('⚛ Profiler', '', 'panel.html', panel => { + panel.onShown.addListener(panel => { + if (currentPanel === panel) { + return; + } + + currentPanel = panel; + profilerPortalContainer = panel.container; + + if (profilerPortalContainer != null) { + ensureInitialHTMLIsCleared(profilerPortalContainer); + render('profiler'); + panel.injectStyles(cloneStyleTags); + } + }); + }); + + chrome.devtools.network.onNavigated.removeListener(checkPageForReact); + + // Shutdown bridge before a new page is loaded. + chrome.webNavigation.onBeforeNavigate.addListener( + function onBeforeNavigate(details) { + // Ignore navigation events from other tabs (or from within frames). + if (details.tabId !== tabId || details.frameId !== 0) { + return; + } + + // `bridge.shutdown()` will remove all listeners we added, so we don't have to. + bridge.shutdown(); + + profilingData = store.profilerStore.profilingData; + } + ); + + // Re-initialize DevTools panel when a new page is loaded. + chrome.devtools.network.onNavigated.addListener(function onNavigated() { + // Re-initialize saved filters on navigation, + // since global values stored on window get reset in this case. + syncSavedPreferences(); + + // It's easiest to recreate the DevTools panel (to clean up potential stale state). + // We can revisit this in the future as a small optimization. + flushSync(() => { + root.unmount(() => { + initBridgeAndStore(); + }); + }); + }); + } + ); +} + +// Load (or reload) the DevTools extension when the user navigates to a new page. +function checkPageForReact() { + syncSavedPreferences(); + createPanelIfReactLoaded(); +} + +chrome.devtools.network.onNavigated.addListener(checkPageForReact); + +// Check to see if React has loaded once per second in case React is added +// after page load +const loadCheckInterval = setInterval(function() { + createPanelIfReactLoaded(); +}, 1000); + +createPanelIfReactLoaded(); diff --git a/extension/shells/browser/shared/src/panel.js b/extension/shells/browser/shared/src/panel.js new file mode 100644 index 0000000000000..f84ac11beb1e7 --- /dev/null +++ b/extension/shells/browser/shared/src/panel.js @@ -0,0 +1,18 @@ +// Portal target container. +window.container = document.getElementById('container'); + +let hasInjectedStyles = false; + +// DevTools styles are injected into the top-level document head (where the main React app is rendered). +// This method copies those styles to the child window where each panel (e.g. Elements, Profiler) is portaled. +window.injectStyles = getLinkTags => { + if (!hasInjectedStyles) { + hasInjectedStyles = true; + + const linkTags = getLinkTags(); + + for (let linkTag of linkTags) { + document.head.appendChild(linkTag); + } + } +}; diff --git a/extension/shells/browser/shared/src/renderer.js b/extension/shells/browser/shared/src/renderer.js new file mode 100644 index 0000000000000..0a0e74808efd0 --- /dev/null +++ b/extension/shells/browser/shared/src/renderer.js @@ -0,0 +1,26 @@ +/** + * In order to support reload-and-profile functionality, the renderer needs to be injected before any other scripts. + * Since it is a complex file (with imports) we can't just toString() it like we do with the hook itself, + * So this entry point (one of the web_accessible_resources) provcides a way to eagerly inject it. + * The hook will look for the presence of a global __REACT_DEVTOOLS_ATTACH__ and attach an injected renderer early. + * The normal case (not a reload-and-profile) will not make use of this entry point though. + * + * @flow + */ + +import { attach } from 'src/backend/renderer'; + +Object.defineProperty( + window, + '__REACT_DEVTOOLS_ATTACH__', + ({ + enumerable: false, + // This property needs to be configurable to allow third-party integrations + // to attach their own renderer. Note that using third-party integrations + // is not officially supported. Use at your own risk. + configurable: true, + get() { + return attach; + }, + }: Object) +); diff --git a/extension/shells/browser/shared/src/utils.js b/extension/shells/browser/shared/src/utils.js new file mode 100644 index 0000000000000..c419244596592 --- /dev/null +++ b/extension/shells/browser/shared/src/utils.js @@ -0,0 +1,51 @@ +/* global chrome */ + +const IS_CHROME = navigator.userAgent.indexOf('Firefox') < 0; + +export function createViewElementSource(bridge: Bridge, store: Store) { + return function viewElementSource(id) { + const rendererID = store.getRendererIDForElement(id); + if (rendererID != null) { + // Ask the renderer interface to determine the component function, + // and store it as a global variable on the window + bridge.send('viewElementSource', { id, rendererID }); + + setTimeout(() => { + // Ask Chrome to display the location of the component function, + // assuming the renderer found one. + chrome.devtools.inspectedWindow.eval(` + if (window.$type != null) { + inspect(window.$type); + } + `); + }, 100); + } + }; +} + +export type BrowserName = 'Chrome' | 'Firefox'; + +export function getBrowserName(): BrowserName { + return IS_CHROME ? 'Chrome' : 'Firefox'; +} + +export type BrowserTheme = 'dark' | 'light'; + +export function getBrowserTheme(): BrowserTheme { + if (IS_CHROME) { + // chrome.devtools.panels added in Chrome 18. + // chrome.devtools.panels.themeName added in Chrome 54. + return chrome.devtools.panels.themeName === 'dark' ? 'dark' : 'light'; + } else { + // chrome.devtools.panels.themeName added in Firefox 55. + // https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/devtools.panels/themeName + if (chrome.devtools && chrome.devtools.panels) { + switch (chrome.devtools.panels.themeName) { + case 'dark': + return 'dark'; + default: + return 'light'; + } + } + } +} diff --git a/extension/shells/browser/shared/webpack.backend.js b/extension/shells/browser/shared/webpack.backend.js new file mode 100644 index 0000000000000..cf034bccef31c --- /dev/null +++ b/extension/shells/browser/shared/webpack.backend.js @@ -0,0 +1,50 @@ +const { resolve } = require('path'); +const { DefinePlugin } = require('webpack'); +const { getGitHubURL, getVersionString } = require('../../utils'); + +const NODE_ENV = process.env.NODE_ENV; +if (!NODE_ENV) { + console.error('NODE_ENV not set'); + process.exit(1); +} + +const __DEV__ = NODE_ENV === 'development'; + +const GITHUB_URL = getGitHubURL(); +const DEVTOOLS_VERSION = getVersionString(); + +module.exports = { + mode: __DEV__ ? 'development' : 'production', + devtool: __DEV__ ? 'cheap-module-eval-source-map' : false, + entry: { + backend: './src/backend.js', + }, + output: { + path: __dirname + '/build', + filename: '[name].js', + }, + resolve: { + alias: { + src: resolve(__dirname, '../../../src'), + }, + }, + plugins: [ + new DefinePlugin({ + __DEV__: true, + __TEST__: false, + 'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`, + 'process.env.GITHUB_URL': `"${GITHUB_URL}"`, + }), + ], + module: { + rules: [ + { + test: /\.js$/, + loader: 'babel-loader', + options: { + configFile: resolve(__dirname, '../../../babel.config.js'), + }, + }, + ], + }, +}; diff --git a/extension/shells/browser/shared/webpack.config.js b/extension/shells/browser/shared/webpack.config.js new file mode 100644 index 0000000000000..23342ca46c446 --- /dev/null +++ b/extension/shells/browser/shared/webpack.config.js @@ -0,0 +1,72 @@ +const { resolve } = require('path'); +const { DefinePlugin } = require('webpack'); +const { getGitHubURL, getVersionString } = require('../../utils'); + +const NODE_ENV = process.env.NODE_ENV; +if (!NODE_ENV) { + console.error('NODE_ENV not set'); + process.exit(1); +} + +const __DEV__ = NODE_ENV === 'development'; + +const GITHUB_URL = getGitHubURL(); +const DEVTOOLS_VERSION = getVersionString(); + +module.exports = { + mode: __DEV__ ? 'development' : 'production', + devtool: __DEV__ ? 'cheap-module-eval-source-map' : false, + entry: { + background: './src/background.js', + contentScript: './src/contentScript.js', + injectGlobalHook: './src/injectGlobalHook.js', + main: './src/main.js', + panel: './src/panel.js', + renderer: './src/renderer.js', + }, + output: { + path: __dirname + '/build', + filename: '[name].js', + }, + resolve: { + alias: { + src: resolve(__dirname, '../../../src'), + }, + }, + plugins: [ + new DefinePlugin({ + __DEV__: false, + __TEST__: false, + 'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`, + 'process.env.GITHUB_URL': `"${GITHUB_URL}"`, + 'process.env.NODE_ENV': `"${NODE_ENV}"`, + }), + ], + module: { + rules: [ + { + test: /\.js$/, + loader: 'babel-loader', + options: { + configFile: resolve(__dirname, '../../../babel.config.js'), + }, + }, + { + test: /\.css$/, + use: [ + { + loader: 'style-loader', + }, + { + loader: 'css-loader', + options: { + sourceMap: true, + modules: true, + localIdentName: '[local]___[hash:base64:5]', + }, + }, + ], + }, + ], + }, +}; diff --git a/extension/shells/dev/app/DeeplyNestedComponents/index.js b/extension/shells/dev/app/DeeplyNestedComponents/index.js new file mode 100644 index 0000000000000..ad4468d2eb1dc --- /dev/null +++ b/extension/shells/dev/app/DeeplyNestedComponents/index.js @@ -0,0 +1,35 @@ +// @flow + +import React, { Fragment } from 'react'; + +function wrapWithHoc(Component, index) { + function HOC() { + return ; + } + HOC.displayName = `withHoc${index}(${Component.displayName || + Component.name})`; + return HOC; +} + +function wrapWithNested(Component, times) { + for (let i = 0; i < times; i++) { + Component = wrapWithHoc(Component, i); + } + + return Component; +} + +function Nested() { + return
Deeply nested div
; +} + +const DeeplyNested = wrapWithNested(Nested, 100); + +export default function DeeplyNestedComponents() { + return ( + +

Deeply nested component

+ +
+ ); +} diff --git a/extension/shells/dev/app/EditableProps/index.js b/extension/shells/dev/app/EditableProps/index.js new file mode 100644 index 0000000000000..f5a39fc92f973 --- /dev/null +++ b/extension/shells/dev/app/EditableProps/index.js @@ -0,0 +1,162 @@ +// @flow + +import React, { + createContext, + Component, + forwardRef, + Fragment, + memo, + useCallback, + useDebugValue, + useEffect, + useReducer, + useState, +} from 'react'; + +const initialData = { foo: 'FOO', bar: 'BAR' }; + +function reducer(state, action) { + switch (action.type) { + case 'swap': + return { foo: state.bar, bar: state.foo }; + default: + throw new Error(); + } +} + +type StatefulFunctionProps = {| name: string |}; + +function StatefulFunction({ name }: StatefulFunctionProps) { + const [count, updateCount] = useState(0); + const debouncedCount = useDebounce(count, 1000); + const handleUpdateCountClick = useCallback(() => updateCount(count + 1), [ + count, + ]); + + const [data, dispatch] = useReducer(reducer, initialData); + const handleUpdateReducerClick = useCallback( + () => dispatch({ type: 'swap' }), + [] + ); + + return ( +
    +
  • Name: {name}
  • +
  • + +
  • +
  • + Reducer state: foo "{data.foo}", bar "{data.bar}" +
  • +
  • + +
  • +
+ ); +} + +const BoolContext = createContext(true); +BoolContext.displayName = 'BoolContext'; + +type Props = {| name: string, toggle: boolean |}; +type State = {| cities: Array, state: string |}; + +class StatefulClass extends Component { + static contextType = BoolContext; + + state: State = { + cities: ['San Francisco', 'San Jose'], + state: 'California', + }; + + handleChange = ({ target }) => + this.setState({ + state: target.value, + }); + + render() { + return ( +
    +
  • Name: {this.props.name}
  • +
  • Toggle: {this.props.toggle ? 'true' : 'false'}
  • +
  • + State: +
  • +
  • Cities: {this.state.cities.join(', ')}
  • +
  • Context: {this.context ? 'true' : 'false'}
  • +
+ ); + } +} + +const MemoizedStatefulClass = memo(StatefulClass); +const MemoizedStatefulFunction = memo(StatefulFunction); + +const ForwardRef = forwardRef<{| name: string |}, HTMLUListElement>( + ({ name }, ref) => { + const [count, updateCount] = useState(0); + const debouncedCount = useDebounce(count, 1000); + const handleUpdateCountClick = useCallback(() => updateCount(count + 1), [ + count, + ]); + return ( +
    +
  • Name: {name}
  • +
  • + +
  • +
+ ); + } +); + +export default function EditableProps() { + return ( + +

Editable props

+ Class + + Function + + Memoized Class + + Memoized Function + + Forward Ref + +
+ ); +} + +// Below copied from https://usehooks.com/ +function useDebounce(value, delay) { + // State and setters for debounced value + const [debouncedValue, setDebouncedValue] = useState(value); + + // Show the value in DevTools + useDebugValue(debouncedValue); + + useEffect( + () => { + // Update debounced value after delay + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + // Cancel the timeout if value changes (also on delay change or unmount) + // This is how we prevent debounced value from updating if value is changed ... + // .. within the delay period. Timeout gets cleared and restarted. + return () => { + clearTimeout(handler); + }; + }, + [value, delay] // Only re-call effect if value or delay changes + ); + + return debouncedValue; +} +// Above copied from https://usehooks.com/ diff --git a/extension/shells/dev/app/ElementTypes/index.js b/extension/shells/dev/app/ElementTypes/index.js new file mode 100644 index 0000000000000..6ef562596c56a --- /dev/null +++ b/extension/shells/dev/app/ElementTypes/index.js @@ -0,0 +1,60 @@ +// @flow + +import React, { + createContext, + forwardRef, + lazy, + memo, + Component, + Fragment, + // $FlowFixMe Flow doesn't know about the Profiler import yet + Profiler, + StrictMode, + Suspense, +} from 'react'; + +const Context = createContext('abc'); +Context.displayName = 'ExampleContext'; + +class ClassComponent extends Component { + render() { + return null; + } +} + +function FunctionComponent() { + return null; +} + +const MemoFunctionComponent = memo(FunctionComponent); + +const ForwardRefComponent = forwardRef((props, ref) => ( + +)); + +const LazyComponent = lazy(() => + Promise.resolve({ + default: FunctionComponent, + }) +); + +export default function ElementTypes() { + return ( + {}}> + + + {value => null} + + + Loading...}> + + + + + + + + + + ); +} diff --git a/extension/shells/dev/app/Hydration/index.js b/extension/shells/dev/app/Hydration/index.js new file mode 100644 index 0000000000000..bf0cd66a527a2 --- /dev/null +++ b/extension/shells/dev/app/Hydration/index.js @@ -0,0 +1,133 @@ +// @flow + +import React, { Fragment, useDebugValue, useState } from 'react'; + +const div = document.createElement('div'); +const exmapleFunction = () => {}; +const typedArray = new Uint8Array(3); +typedArray[0] = 1; +typedArray[1] = 2; +typedArray[2] = 3; + +const arrayOfArrays = [ + [['a', 'b', 'c'], ['d', 'e', 'f'], ['h', 'i', 'j']], + [['k', 'l', 'm'], ['n', 'o', 'p'], ['q', 'r', 's']], + [['t', 'u', 'v'], ['w', 'x', 'y'], ['z']], + [], +]; + +const objectOfObjects = { + foo: { + a: 1, + b: 2, + c: 3, + }, + bar: { + e: 4, + f: 5, + g: 6, + }, + baz: { + h: 7, + i: 8, + j: 9, + }, + qux: {}, +}; + +function useOuterFoo() { + useDebugValue({ + debugA: { + debugB: { + debugC: 'abc', + }, + }, + }); + useState({ + valueA: { + valueB: { + valueC: 'abc', + }, + }, + }); + return useInnerFoo(); +} + +function useInnerFoo() { + const [value] = useState([[['a', 'b', 'c']]]); + return value; +} + +function useOuterBar() { + useDebugValue({ + debugA: { + debugB: { + debugC: 'abc', + }, + }, + }); + return useInnerBar(); +} + +function useInnerBar() { + useDebugValue({ + debugA: { + debugB: { + debugC: 'abc', + }, + }, + }); + const [count] = useState(123); + return count; +} + +function useOuterBaz() { + return useInnerBaz(); +} + +function useInnerBaz() { + const [count] = useState(123); + return count; +} + +export default function Hydration() { + return ( + +

Hydration

+ } + array_buffer={typedArray.buffer} + typed_array={typedArray} + date={new Date()} + array={arrayOfArrays} + object={objectOfObjects} + /> + +
+ ); +} + +function DehydratableProps({ array, object }: any) { + return ( +
    +
  • array: {JSON.stringify(array, null, 2)}
  • +
  • object: {JSON.stringify(object, null, 2)}
  • +
+ ); +} + +function DeepHooks(props: any) { + const foo = useOuterFoo(); + const bar = useOuterBar(); + const baz = useOuterBaz(); + return ( +
    +
  • foo: {foo}
  • +
  • bar: {bar}
  • +
  • baz: {baz}
  • +
+ ); +} diff --git a/extension/shells/dev/app/Iframe/index.js b/extension/shells/dev/app/Iframe/index.js new file mode 100644 index 0000000000000..8e4967ae5aca2 --- /dev/null +++ b/extension/shells/dev/app/Iframe/index.js @@ -0,0 +1,69 @@ +/** @flow */ + +import React, { Fragment } from 'react'; +import ReactDOM from 'react-dom'; + +export default function Iframe() { + return ( + +

Iframe

+
+ + + +
+
+ ); +} + +const iframeStyle = { border: '2px solid #eee', height: 80 }; + +function Frame(props) { + const [element, setElement] = React.useState(null); + + const ref = React.useRef(); + + React.useLayoutEffect(function() { + const iframe = ref.current; + + if (iframe) { + const html = ` + + + +
+ + + `; + + const document = iframe.contentDocument; + + document.open(); + document.write(html); + document.close(); + + setElement(document.getElementById('root')); + } + }, []); + + return ( + + + + +
+ + + + + + + \ No newline at end of file diff --git a/extension/shells/dev/now.json b/extension/shells/dev/now.json new file mode 100644 index 0000000000000..7c0805c3b1656 --- /dev/null +++ b/extension/shells/dev/now.json @@ -0,0 +1,5 @@ +{ + "name": "react-devtools-experimental", + "alias": ["react-devtools-experimental"], + "files": ["index.html", "dist"] +} diff --git a/extension/shells/dev/src/devtools.js b/extension/shells/dev/src/devtools.js new file mode 100644 index 0000000000000..230ddc94d2bd7 --- /dev/null +++ b/extension/shells/dev/src/devtools.js @@ -0,0 +1,77 @@ +/** @flow */ + +import { createElement } from 'react'; +// $FlowFixMe Flow does not yet know about createRoot() +import { unstable_createRoot as createRoot } from 'react-dom'; +import { + activate as activateBackend, + initialize as initializeBackend, +} from 'react-devtools-inline/backend'; +import { initialize as initializeFrontend } from 'react-devtools-inline/frontend'; +import { initDevTools } from 'src/devtools'; + +const iframe = ((document.getElementById('target'): any): HTMLIFrameElement); + +const { contentDocument, contentWindow } = iframe; + +// Helps with positioning Overlay UI. +contentWindow.__REACT_DEVTOOLS_TARGET_WINDOW__ = window; + +initializeBackend(contentWindow); + +const container = ((document.getElementById('devtools'): any): HTMLElement); + +let isTestAppMounted = true; + +const mountButton = ((document.getElementById( + 'mountButton' +): any): HTMLButtonElement); +mountButton.addEventListener('click', function() { + if (isTestAppMounted) { + if (typeof window.unmountTestApp === 'function') { + window.unmountTestApp(); + mountButton.innerText = 'Mount test app'; + isTestAppMounted = false; + } + } else { + if (typeof window.mountTestApp === 'function') { + window.mountTestApp(); + mountButton.innerText = 'Unmount test app'; + isTestAppMounted = true; + } + } +}); + +inject('dist/app.js', () => { + initDevTools({ + connect(cb) { + const DevTools = initializeFrontend(contentWindow); + + // Activate the backend only once the DevTools frontend Store has been initialized. + // Otherwise the Store may miss important initial tree op codes. + activateBackend(contentWindow); + + const root = createRoot(container); + root.render( + createElement(DevTools, { + browserTheme: 'light', + showTabBar: true, + showWelcomeToTheNewDevToolsDialog: true, + warnIfLegacyBackendDetected: true, + }) + ); + }, + + onReload(reloadFn) { + iframe.onload = reloadFn; + }, + }); +}); + +function inject(sourcePath, callback) { + const script = contentDocument.createElement('script'); + script.onload = callback; + script.src = sourcePath; + + ((contentDocument.body: any): HTMLBodyElement).appendChild(script); +} diff --git a/extension/shells/dev/webpack.config.js b/extension/shells/dev/webpack.config.js new file mode 100644 index 0000000000000..ce58e6418a180 --- /dev/null +++ b/extension/shells/dev/webpack.config.js @@ -0,0 +1,92 @@ +const { resolve } = require('path'); +const { DefinePlugin } = require('webpack'); +const { getGitHubURL, getVersionString } = require('../utils'); + +const NODE_ENV = process.env.NODE_ENV; +if (!NODE_ENV) { + console.error('NODE_ENV not set'); + process.exit(1); +} + +const TARGET = process.env.TARGET; +if (!TARGET) { + console.error('TARGET not set'); + process.exit(1); +} + +const __DEV__ = NODE_ENV === 'development'; + +const root = resolve(__dirname, '../..'); + +const GITHUB_URL = getGitHubURL(); +const DEVTOOLS_VERSION = getVersionString(); + +const config = { + mode: __DEV__ ? 'development' : 'production', + devtool: false, + entry: { + app: './app/index.js', + devtools: './src/devtools.js', + }, + resolve: { + alias: { + 'react-devtools-inline': resolve( + root, + 'packages/react-devtools-inline/src/' + ), + src: resolve(root, 'src'), + }, + }, + plugins: [ + new DefinePlugin({ + __DEV__, + __TEST__: false, + 'process.env.GITHUB_URL': `"${GITHUB_URL}"`, + 'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`, + }), + ], + module: { + rules: [ + { + test: /\.js$/, + loader: 'babel-loader', + options: { + configFile: resolve(root, 'babel.config.js'), + }, + }, + { + test: /\.css$/, + use: [ + { + loader: 'style-loader', + }, + { + loader: 'css-loader', + options: { + sourceMap: true, + modules: true, + localIdentName: '[local]___[hash:base64:5]', + }, + }, + ], + }, + ], + }, +}; + +if (TARGET === 'local') { + config.devServer = { + hot: true, + port: 8080, + clientLogLevel: 'warning', + publicPath: '/dist/', + stats: 'errors-only', + }; +} else { + config.output = { + path: resolve(__dirname, 'dist'), + filename: '[name].js', + }; +} + +module.exports = config; diff --git a/extension/shells/utils.js b/extension/shells/utils.js new file mode 100644 index 0000000000000..515f5c242b6a9 --- /dev/null +++ b/extension/shells/utils.js @@ -0,0 +1,37 @@ +const { execSync } = require('child_process'); +const { readFileSync } = require('fs'); +const { resolve } = require('path'); + +function getGitCommit() { + return execSync('git show -s --format=%h') + .toString() + .trim(); +} + +function getGitHubURL() { + // TODO potentially replace this with an fb.me URL (assuming it can forward the query params) + const url = execSync('git remote get-url origin') + .toString() + .trim(); + + if (url.startsWith('https://')) { + return url.replace('.git', ''); + } else { + return url + .replace(':', '/') + .replace('git@', 'https://') + .replace('.git', ''); + } +} + +function getVersionString() { + const packageVersion = JSON.parse( + readFileSync(resolve(__dirname, '../package.json')) + ).version; + + const commit = getGitCommit(); + + return `${packageVersion}-${commit}`; +} + +module.exports = { getGitCommit, getGitHubURL, getVersionString }; diff --git a/extension/src/__tests__/__mocks__/cssMock.js b/extension/src/__tests__/__mocks__/cssMock.js new file mode 100644 index 0000000000000..f053ebf7976e3 --- /dev/null +++ b/extension/src/__tests__/__mocks__/cssMock.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/extension/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap b/extension/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap new file mode 100644 index 0000000000000..a27a73527ca04 --- /dev/null +++ b/extension/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap @@ -0,0 +1,478 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +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 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 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": { + "html_element": {}, + "fn": {}, + "symbol": {}, + "react_element": {}, + "array_buffer": {}, + "typed_array": {}, + "date": {} + }, + "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 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/extension/src/__tests__/__snapshots__/ownersListContext-test.js.snap b/extension/src/__tests__/__snapshots__/ownersListContext-test.js.snap new file mode 100644 index 0000000000000..0699eb5b18dc5 --- /dev/null +++ b/extension/src/__tests__/__snapshots__/ownersListContext-test.js.snap @@ -0,0 +1,95 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OwnersListContext should fetch the owners list for the selected element that includes filtered components: mount 1`] = ` +[root] + ▾ + + +`; + +exports[`OwnersListContext should fetch the owners list for the selected element that includes filtered components: owners for "Child" 1`] = ` +Array [ + Object { + "displayName": "Grandparent", + "hocDisplayNames": null, + "id": 7, + "type": 5, + }, + Object { + "displayName": "Parent", + "hocDisplayNames": null, + "id": 9, + "type": 5, + }, + Object { + "displayName": "Child", + "hocDisplayNames": null, + "id": 8, + "type": 5, + }, +] +`; + +exports[`OwnersListContext should fetch the owners list for the selected element: mount 1`] = ` +[root] + ▾ + ▾ + + +`; + +exports[`OwnersListContext should fetch the owners list for the selected element: owners for "Child" 1`] = ` +Array [ + Object { + "displayName": "Grandparent", + "hocDisplayNames": null, + "id": 2, + "type": 5, + }, + Object { + "displayName": "Parent", + "hocDisplayNames": null, + "id": 3, + "type": 5, + }, + Object { + "displayName": "Child", + "hocDisplayNames": null, + "id": 4, + "type": 5, + }, +] +`; + +exports[`OwnersListContext should fetch the owners list for the selected element: owners for "Parent" 1`] = ` +Array [ + Object { + "displayName": "Grandparent", + "hocDisplayNames": null, + "id": 2, + "type": 5, + }, + Object { + "displayName": "Parent", + "hocDisplayNames": null, + "id": 3, + "type": 5, + }, +] +`; + +exports[`OwnersListContext should include the current element even if there are no other owners: mount 1`] = ` +[root] + +`; + +exports[`OwnersListContext should include the current element even if there are no other owners: owners for "Grandparent" 1`] = ` +Array [ + Object { + "displayName": "Grandparent", + "hocDisplayNames": null, + "id": 5, + "type": 5, + }, +] +`; diff --git a/extension/src/__tests__/__snapshots__/profilerContext-test.js.snap b/extension/src/__tests__/__snapshots__/profilerContext-test.js.snap new file mode 100644 index 0000000000000..927f3180b4219 --- /dev/null +++ b/extension/src/__tests__/__snapshots__/profilerContext-test.js.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProfilerContext should auto-select the root ID matching the Components tab selection if it has profiling data: mounted 1`] = ` +[root] + ▾ + +[root] + ▾ + +`; + +exports[`ProfilerContext should maintain root selection between profiling sessions so long as there is data for that root: mounted 1`] = ` +[root] + ▾ + +[root] + ▾ + +`; + +exports[`ProfilerContext should not select the root ID matching the Components tab selection if it has no profiling data: mounted 1`] = ` +[root] + ▾ + +[root] + ▾ + +`; + +exports[`ProfilerContext should sync selected element in the Components tab too, provided the element is a match: mounted 1`] = ` +[root] + ▾ + ▾ + +`; + +exports[`ProfilerContext should sync selected element in the Components tab too, provided the element is a match: updated 1`] = ` +[root] + ▾ + +`; diff --git a/extension/src/__tests__/__snapshots__/profilingCache-test.js.snap b/extension/src/__tests__/__snapshots__/profilingCache-test.js.snap new file mode 100644 index 0000000000000..82438ada7ca35 --- /dev/null +++ b/extension/src/__tests__/__snapshots__/profilingCache-test.js.snap @@ -0,0 +1,3632 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProfilingCache should calculate a self duration based on actual children (not filtered children): CommitDetails with filtered self durations 1`] = ` +Object { + "changeDescriptions": Map { + 2 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + 3 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + 5 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + }, + "duration": 16, + "fiberActualDurations": Map { + 1 => 16, + 2 => 16, + 3 => 1, + 5 => 1, + }, + "fiberSelfDurations": Map { + 1 => 0, + 2 => 10, + 3 => 1, + 5 => 1, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 16, +} +`; + +exports[`ProfilingCache should calculate self duration correctly for suspended views: CommitDetails with filtered self durations 1`] = ` +Object { + "changeDescriptions": Map { + 2 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + 4 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + }, + "duration": 15, + "fiberActualDurations": Map { + 1 => 15, + 2 => 15, + 3 => 5, + 4 => 2, + }, + "fiberSelfDurations": Map { + 1 => 0, + 2 => 10, + 3 => 3, + 4 => 2, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 15, +} +`; + +exports[`ProfilingCache should calculate self duration correctly for suspended views: CommitDetails with filtered self durations 2`] = ` +Object { + "changeDescriptions": Map { + 5 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + }, + "duration": 3, + "fiberActualDurations": Map { + 5 => 3, + 3 => 3, + }, + "fiberSelfDurations": Map { + 5 => 3, + 3 => 0, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 18, +} +`; + +exports[`ProfilingCache should collect data for each commit: CommitDetails commitIndex: 0 1`] = ` +Object { + "changeDescriptions": Map { + 2 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + 3 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + 4 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + 5 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + }, + "duration": 12, + "fiberActualDurations": Map { + 1 => 12, + 2 => 12, + 3 => 0, + 4 => 1, + 5 => 1, + }, + "fiberSelfDurations": Map { + 1 => 0, + 2 => 10, + 3 => 0, + 4 => 1, + 5 => 1, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 12, +} +`; + +exports[`ProfilingCache should collect data for each commit: CommitDetails commitIndex: 1 1`] = ` +Object { + "changeDescriptions": Map { + 3 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 4 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 6 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + 2 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "count", + ], + "state": null, + }, + }, + "duration": 13, + "fiberActualDurations": Map { + 3 => 0, + 4 => 1, + 6 => 2, + 2 => 13, + 1 => 13, + }, + "fiberSelfDurations": Map { + 3 => 0, + 4 => 1, + 6 => 2, + 2 => 10, + 1 => 0, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 25, +} +`; + +exports[`ProfilingCache should collect data for each commit: CommitDetails commitIndex: 2 1`] = ` +Object { + "changeDescriptions": Map { + 3 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 2 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "count", + ], + "state": null, + }, + }, + "duration": 10, + "fiberActualDurations": Map { + 3 => 0, + 2 => 10, + 1 => 10, + }, + "fiberSelfDurations": Map { + 3 => 0, + 2 => 10, + 1 => 0, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 35, +} +`; + +exports[`ProfilingCache should collect data for each commit: CommitDetails commitIndex: 3 1`] = ` +Object { + "changeDescriptions": Map { + 2 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "count", + ], + "state": null, + }, + }, + "duration": 10, + "fiberActualDurations": Map { + 2 => 10, + 1 => 10, + }, + "fiberSelfDurations": Map { + 2 => 10, + 1 => 0, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 45, +} +`; + +exports[`ProfilingCache should collect data for each commit: imported data 1`] = ` +Object { + "dataForRoots": Array [ + Object { + "commitData": Array [ + Object { + "changeDescriptions": Array [ + Array [ + 2, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + Array [ + 3, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + Array [ + 4, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + Array [ + 5, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + ], + "duration": 12, + "fiberActualDurations": Array [ + Array [ + 1, + 12, + ], + Array [ + 2, + 12, + ], + Array [ + 3, + 0, + ], + Array [ + 4, + 1, + ], + Array [ + 5, + 1, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 1, + 0, + ], + Array [ + 2, + 10, + ], + Array [ + 3, + 0, + ], + Array [ + 4, + 1, + ], + Array [ + 5, + 1, + ], + ], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 12, + }, + Object { + "changeDescriptions": Array [ + Array [ + 3, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 4, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 6, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + Array [ + 2, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "count", + ], + "state": null, + }, + ], + ], + "duration": 13, + "fiberActualDurations": Array [ + Array [ + 3, + 0, + ], + Array [ + 4, + 1, + ], + Array [ + 6, + 2, + ], + Array [ + 2, + 13, + ], + Array [ + 1, + 13, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 3, + 0, + ], + Array [ + 4, + 1, + ], + Array [ + 6, + 2, + ], + Array [ + 2, + 10, + ], + Array [ + 1, + 0, + ], + ], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 25, + }, + Object { + "changeDescriptions": Array [ + Array [ + 3, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 2, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "count", + ], + "state": null, + }, + ], + ], + "duration": 10, + "fiberActualDurations": Array [ + Array [ + 3, + 0, + ], + Array [ + 2, + 10, + ], + Array [ + 1, + 10, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 3, + 0, + ], + Array [ + 2, + 10, + ], + Array [ + 1, + 0, + ], + ], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 35, + }, + Object { + "changeDescriptions": Array [ + Array [ + 2, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "count", + ], + "state": null, + }, + ], + ], + "duration": 10, + "fiberActualDurations": Array [ + Array [ + 2, + 10, + ], + Array [ + 1, + 10, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 2, + 10, + ], + Array [ + 1, + 0, + ], + ], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 45, + }, + ], + "displayName": "Parent", + "initialTreeBaseDurations": Array [], + "interactionCommits": Array [], + "interactions": Array [], + "operations": Array [ + Array [ + 1, + 1, + 17, + 6, + 80, + 97, + 114, + 101, + 110, + 116, + 5, + 67, + 104, + 105, + 108, + 100, + 1, + 48, + 1, + 49, + 1, + 1, + 11, + 1, + 1, + 4, + 1, + 12000, + 1, + 2, + 5, + 1, + 0, + 1, + 0, + 4, + 2, + 12000, + 1, + 3, + 5, + 2, + 2, + 2, + 3, + 4, + 3, + 0, + 1, + 4, + 5, + 2, + 2, + 2, + 4, + 4, + 4, + 1000, + 1, + 5, + 8, + 2, + 2, + 2, + 0, + 4, + 5, + 1000, + ], + Array [ + 1, + 1, + 8, + 5, + 67, + 104, + 105, + 108, + 100, + 1, + 50, + 1, + 6, + 5, + 2, + 2, + 1, + 2, + 4, + 6, + 2000, + 4, + 2, + 14000, + 3, + 2, + 4, + 3, + 4, + 6, + 5, + 4, + 1, + 14000, + ], + Array [ + 1, + 1, + 0, + 2, + 2, + 6, + 4, + 4, + 2, + 11000, + 3, + 2, + 2, + 3, + 5, + 4, + 1, + 11000, + ], + Array [ + 1, + 1, + 0, + 2, + 1, + 3, + ], + ], + "rootID": 1, + "snapshots": Array [], + }, + ], + "version": 4, +} +`; + +exports[`ProfilingCache should collect data for each rendered fiber: FiberCommits: element 2 1`] = ` +Array [ + 0, + 1, + 2, +] +`; + +exports[`ProfilingCache should collect data for each rendered fiber: FiberCommits: element 3 1`] = ` +Array [ + 0, + 1, + 2, +] +`; + +exports[`ProfilingCache should collect data for each rendered fiber: FiberCommits: element 4 1`] = ` +Array [ + 0, +] +`; + +exports[`ProfilingCache should collect data for each rendered fiber: FiberCommits: element 5 1`] = ` +Array [ + 1, + 2, +] +`; + +exports[`ProfilingCache should collect data for each rendered fiber: FiberCommits: element 6 1`] = ` +Array [ + 2, +] +`; + +exports[`ProfilingCache should collect data for each rendered fiber: imported data 1`] = ` +Object { + "dataForRoots": Array [ + Object { + "commitData": Array [ + Object { + "changeDescriptions": Array [ + Array [ + 2, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + Array [ + 3, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + Array [ + 4, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + ], + "duration": 11, + "fiberActualDurations": Array [ + Array [ + 1, + 11, + ], + Array [ + 2, + 11, + ], + Array [ + 3, + 0, + ], + Array [ + 4, + 1, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 1, + 0, + ], + Array [ + 2, + 10, + ], + Array [ + 3, + 0, + ], + Array [ + 4, + 1, + ], + ], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 11, + }, + Object { + "changeDescriptions": Array [ + Array [ + 3, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 5, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + Array [ + 2, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "count", + ], + "state": null, + }, + ], + ], + "duration": 11, + "fiberActualDurations": Array [ + Array [ + 3, + 0, + ], + Array [ + 5, + 1, + ], + Array [ + 2, + 11, + ], + Array [ + 1, + 11, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 3, + 0, + ], + Array [ + 5, + 1, + ], + Array [ + 2, + 10, + ], + Array [ + 1, + 0, + ], + ], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 22, + }, + Object { + "changeDescriptions": Array [ + Array [ + 3, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 5, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 6, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + Array [ + 2, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "count", + ], + "state": null, + }, + ], + ], + "duration": 13, + "fiberActualDurations": Array [ + Array [ + 3, + 0, + ], + Array [ + 5, + 1, + ], + Array [ + 6, + 2, + ], + Array [ + 2, + 13, + ], + Array [ + 1, + 13, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 3, + 0, + ], + Array [ + 5, + 1, + ], + Array [ + 6, + 2, + ], + Array [ + 2, + 10, + ], + Array [ + 1, + 0, + ], + ], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 35, + }, + ], + "displayName": "Parent", + "initialTreeBaseDurations": Array [], + "interactionCommits": Array [], + "interactions": Array [], + "operations": Array [ + Array [ + 1, + 1, + 15, + 6, + 80, + 97, + 114, + 101, + 110, + 116, + 5, + 67, + 104, + 105, + 108, + 100, + 1, + 48, + 1, + 1, + 11, + 1, + 1, + 4, + 1, + 11000, + 1, + 2, + 5, + 1, + 0, + 1, + 0, + 4, + 2, + 11000, + 1, + 3, + 5, + 2, + 2, + 2, + 3, + 4, + 3, + 0, + 1, + 4, + 8, + 2, + 2, + 2, + 0, + 4, + 4, + 1000, + ], + Array [ + 1, + 1, + 8, + 5, + 67, + 104, + 105, + 108, + 100, + 1, + 49, + 1, + 5, + 5, + 2, + 2, + 1, + 2, + 4, + 5, + 1000, + 4, + 2, + 12000, + 3, + 2, + 3, + 3, + 5, + 4, + 4, + 1, + 12000, + ], + Array [ + 1, + 1, + 8, + 5, + 67, + 104, + 105, + 108, + 100, + 1, + 50, + 1, + 6, + 5, + 2, + 2, + 1, + 2, + 4, + 6, + 2000, + 4, + 2, + 14000, + 3, + 2, + 4, + 3, + 5, + 6, + 4, + 4, + 1, + 14000, + ], + ], + "rootID": 1, + "snapshots": Array [], + }, + ], + "version": 4, +} +`; + +exports[`ProfilingCache should collect data for each root (including ones added or mounted after profiling started): Data for root Parent 1`] = ` +Object { + "commitData": Array [ + Object { + "changeDescriptions": Map { + 3 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 4 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 10 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + 2 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "count", + ], + "state": null, + }, + }, + "duration": 13, + "fiberActualDurations": Map { + 3 => 0, + 4 => 1, + 10 => 2, + 2 => 13, + 1 => 13, + }, + "fiberSelfDurations": Map { + 3 => 0, + 4 => 1, + 10 => 2, + 2 => 10, + 1 => 0, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 13, + }, + Object { + "changeDescriptions": Map { + 3 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 2 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "count", + ], + "state": null, + }, + }, + "duration": 10, + "fiberActualDurations": Map { + 3 => 0, + 2 => 10, + 1 => 10, + }, + "fiberSelfDurations": Map { + 3 => 0, + 2 => 10, + 1 => 0, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 34, + }, + Object { + "changeDescriptions": Map { + 2 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "count", + ], + "state": null, + }, + }, + "duration": 10, + "fiberActualDurations": Map { + 2 => 10, + 1 => 10, + }, + "fiberSelfDurations": Map { + 2 => 10, + 1 => 0, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 44, + }, + ], + "displayName": "Parent", + "initialTreeBaseDurations": Map { + 1 => 12, + 2 => 12, + 3 => 0, + 4 => 1, + 5 => 1, + }, + "interactionCommits": Map {}, + "interactions": Map {}, + "operations": Array [ + Array [ + 1, + 1, + 8, + 5, + 67, + 104, + 105, + 108, + 100, + 1, + 50, + 1, + 10, + 5, + 2, + 2, + 1, + 2, + 4, + 10, + 2000, + 4, + 2, + 14000, + 3, + 2, + 4, + 3, + 4, + 10, + 5, + 4, + 1, + 14000, + ], + Array [ + 1, + 1, + 0, + 2, + 2, + 10, + 4, + 4, + 2, + 11000, + 3, + 2, + 2, + 3, + 5, + 4, + 1, + 11000, + ], + Array [ + 1, + 1, + 0, + 2, + 1, + 3, + ], + ], + "rootID": 1, + "snapshots": Map { + 1 => Object { + "children": Array [ + 2, + ], + "displayName": null, + "id": 1, + "key": null, + "type": 11, + }, + 2 => Object { + "children": Array [ + 3, + 4, + 5, + ], + "displayName": "Parent", + "id": 2, + "key": null, + "type": 5, + }, + 3 => Object { + "children": Array [], + "displayName": "Child", + "id": 3, + "key": "0", + "type": 5, + }, + 4 => Object { + "children": Array [], + "displayName": "Child", + "id": 4, + "key": "1", + "type": 5, + }, + 5 => Object { + "children": Array [], + "displayName": "Child", + "id": 5, + "key": null, + "type": 8, + }, + }, +} +`; + +exports[`ProfilingCache should collect data for each root (including ones added or mounted after profiling started): Data for root Parent 2`] = ` +Object { + "commitData": Array [ + Object { + "changeDescriptions": Map { + 12 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + 13 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + 14 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + }, + "duration": 11, + "fiberActualDurations": Map { + 11 => 11, + 12 => 11, + 13 => 0, + 14 => 1, + }, + "fiberSelfDurations": Map { + 11 => 0, + 12 => 10, + 13 => 0, + 14 => 1, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 24, + }, + ], + "displayName": "Parent", + "initialTreeBaseDurations": Map {}, + "interactionCommits": Map {}, + "interactions": Map {}, + "operations": Array [ + Array [ + 1, + 11, + 15, + 6, + 80, + 97, + 114, + 101, + 110, + 116, + 5, + 67, + 104, + 105, + 108, + 100, + 1, + 48, + 1, + 11, + 11, + 1, + 1, + 4, + 11, + 11000, + 1, + 12, + 5, + 11, + 0, + 1, + 0, + 4, + 12, + 11000, + 1, + 13, + 5, + 12, + 12, + 2, + 3, + 4, + 13, + 0, + 1, + 14, + 8, + 12, + 12, + 2, + 0, + 4, + 14, + 1000, + ], + ], + "rootID": 11, + "snapshots": Map {}, +} +`; + +exports[`ProfilingCache should collect data for each root (including ones added or mounted after profiling started): Data for root Parent 3`] = ` +Object { + "commitData": Array [ + Object { + "changeDescriptions": Map {}, + "duration": 0, + "fiberActualDurations": Map {}, + "fiberSelfDurations": Map {}, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 34, + }, + ], + "displayName": "Parent", + "initialTreeBaseDurations": Map { + 6 => 11, + 7 => 11, + 8 => 0, + 9 => 1, + }, + "interactionCommits": Map {}, + "interactions": Map {}, + "operations": Array [ + Array [ + 1, + 6, + 0, + 2, + 4, + 9, + 8, + 7, + 6, + ], + ], + "rootID": 6, + "snapshots": Map { + 6 => Object { + "children": Array [ + 7, + ], + "displayName": null, + "id": 6, + "key": null, + "type": 11, + }, + 7 => Object { + "children": Array [ + 8, + 9, + ], + "displayName": "Parent", + "id": 7, + "key": null, + "type": 5, + }, + 8 => Object { + "children": Array [], + "displayName": "Child", + "id": 8, + "key": "0", + "type": 5, + }, + 9 => Object { + "children": Array [], + "displayName": "Child", + "id": 9, + "key": null, + "type": 8, + }, + }, +} +`; + +exports[`ProfilingCache should collect data for each root (including ones added or mounted after profiling started): imported data 1`] = ` +Object { + "dataForRoots": Array [ + Object { + "commitData": Array [ + Object { + "changeDescriptions": Array [ + Array [ + 3, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 4, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 10, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + Array [ + 2, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "count", + ], + "state": null, + }, + ], + ], + "duration": 13, + "fiberActualDurations": Array [ + Array [ + 3, + 0, + ], + Array [ + 4, + 1, + ], + Array [ + 10, + 2, + ], + Array [ + 2, + 13, + ], + Array [ + 1, + 13, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 3, + 0, + ], + Array [ + 4, + 1, + ], + Array [ + 10, + 2, + ], + Array [ + 2, + 10, + ], + Array [ + 1, + 0, + ], + ], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 13, + }, + Object { + "changeDescriptions": Array [ + Array [ + 3, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 2, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "count", + ], + "state": null, + }, + ], + ], + "duration": 10, + "fiberActualDurations": Array [ + Array [ + 3, + 0, + ], + Array [ + 2, + 10, + ], + Array [ + 1, + 10, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 3, + 0, + ], + Array [ + 2, + 10, + ], + Array [ + 1, + 0, + ], + ], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 34, + }, + Object { + "changeDescriptions": Array [ + Array [ + 2, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "count", + ], + "state": null, + }, + ], + ], + "duration": 10, + "fiberActualDurations": Array [ + Array [ + 2, + 10, + ], + Array [ + 1, + 10, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 2, + 10, + ], + Array [ + 1, + 0, + ], + ], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 44, + }, + ], + "displayName": "Parent", + "initialTreeBaseDurations": Array [ + Array [ + 1, + 12, + ], + Array [ + 2, + 12, + ], + Array [ + 3, + 0, + ], + Array [ + 4, + 1, + ], + Array [ + 5, + 1, + ], + ], + "interactionCommits": Array [], + "interactions": Array [], + "operations": Array [ + Array [ + 1, + 1, + 8, + 5, + 67, + 104, + 105, + 108, + 100, + 1, + 50, + 1, + 10, + 5, + 2, + 2, + 1, + 2, + 4, + 10, + 2000, + 4, + 2, + 14000, + 3, + 2, + 4, + 3, + 4, + 10, + 5, + 4, + 1, + 14000, + ], + Array [ + 1, + 1, + 0, + 2, + 2, + 10, + 4, + 4, + 2, + 11000, + 3, + 2, + 2, + 3, + 5, + 4, + 1, + 11000, + ], + Array [ + 1, + 1, + 0, + 2, + 1, + 3, + ], + ], + "rootID": 1, + "snapshots": Array [ + Array [ + 1, + Object { + "children": Array [ + 2, + ], + "displayName": null, + "id": 1, + "key": null, + "type": 11, + }, + ], + Array [ + 2, + Object { + "children": Array [ + 3, + 4, + 5, + ], + "displayName": "Parent", + "id": 2, + "key": null, + "type": 5, + }, + ], + Array [ + 3, + Object { + "children": Array [], + "displayName": "Child", + "id": 3, + "key": "0", + "type": 5, + }, + ], + Array [ + 4, + Object { + "children": Array [], + "displayName": "Child", + "id": 4, + "key": "1", + "type": 5, + }, + ], + Array [ + 5, + Object { + "children": Array [], + "displayName": "Child", + "id": 5, + "key": null, + "type": 8, + }, + ], + ], + }, + Object { + "commitData": Array [ + Object { + "changeDescriptions": Array [ + Array [ + 12, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + Array [ + 13, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + Array [ + 14, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + ], + "duration": 11, + "fiberActualDurations": Array [ + Array [ + 11, + 11, + ], + Array [ + 12, + 11, + ], + Array [ + 13, + 0, + ], + Array [ + 14, + 1, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 11, + 0, + ], + Array [ + 12, + 10, + ], + Array [ + 13, + 0, + ], + Array [ + 14, + 1, + ], + ], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 24, + }, + ], + "displayName": "Parent", + "initialTreeBaseDurations": Array [], + "interactionCommits": Array [], + "interactions": Array [], + "operations": Array [ + Array [ + 1, + 11, + 15, + 6, + 80, + 97, + 114, + 101, + 110, + 116, + 5, + 67, + 104, + 105, + 108, + 100, + 1, + 48, + 1, + 11, + 11, + 1, + 1, + 4, + 11, + 11000, + 1, + 12, + 5, + 11, + 0, + 1, + 0, + 4, + 12, + 11000, + 1, + 13, + 5, + 12, + 12, + 2, + 3, + 4, + 13, + 0, + 1, + 14, + 8, + 12, + 12, + 2, + 0, + 4, + 14, + 1000, + ], + ], + "rootID": 11, + "snapshots": Array [], + }, + Object { + "commitData": Array [ + Object { + "changeDescriptions": Array [], + "duration": 0, + "fiberActualDurations": Array [], + "fiberSelfDurations": Array [], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 34, + }, + ], + "displayName": "Parent", + "initialTreeBaseDurations": Array [ + Array [ + 6, + 11, + ], + Array [ + 7, + 11, + ], + Array [ + 8, + 0, + ], + Array [ + 9, + 1, + ], + ], + "interactionCommits": Array [], + "interactions": Array [], + "operations": Array [ + Array [ + 1, + 6, + 0, + 2, + 4, + 9, + 8, + 7, + 6, + ], + ], + "rootID": 6, + "snapshots": Array [ + Array [ + 6, + Object { + "children": Array [ + 7, + ], + "displayName": null, + "id": 6, + "key": null, + "type": 11, + }, + ], + Array [ + 7, + Object { + "children": Array [ + 8, + 9, + ], + "displayName": "Parent", + "id": 7, + "key": null, + "type": 5, + }, + ], + Array [ + 8, + Object { + "children": Array [], + "displayName": "Child", + "id": 8, + "key": "0", + "type": 5, + }, + ], + Array [ + 9, + Object { + "children": Array [], + "displayName": "Child", + "id": 9, + "key": null, + "type": 8, + }, + ], + ], + }, + ], + "version": 4, +} +`; + +exports[`ProfilingCache should record changed props/state/context/hooks: CommitDetails commitIndex: 0 1`] = ` +Object { + "changeDescriptions": Map { + 2 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + 4 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + 5 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + 6 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + 7 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + }, + "duration": 0, + "fiberActualDurations": Map { + 1 => 0, + 2 => 0, + 3 => 0, + 4 => 0, + 5 => 0, + 6 => 0, + 7 => 0, + }, + "fiberSelfDurations": Map { + 1 => 0, + 2 => 0, + 3 => 0, + 4 => 0, + 5 => 0, + 6 => 0, + 7 => 0, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 0, +} +`; + +exports[`ProfilingCache should record changed props/state/context/hooks: CommitDetails commitIndex: 1 1`] = ` +Object { + "changeDescriptions": Map { + 5 => Object { + "context": null, + "didHooksChange": true, + "isFirstMount": false, + "props": Array [ + "count", + ], + "state": null, + }, + 4 => Object { + "context": true, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 7 => Object { + "context": null, + "didHooksChange": true, + "isFirstMount": false, + "props": Array [ + "count", + ], + "state": null, + }, + 6 => Object { + "context": Array [ + "count", + ], + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 2 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": Array [ + "count", + ], + }, + }, + "duration": 0, + "fiberActualDurations": Map { + 5 => 0, + 4 => 0, + 7 => 0, + 6 => 0, + 3 => 0, + 2 => 0, + }, + "fiberSelfDurations": Map { + 5 => 0, + 4 => 0, + 7 => 0, + 6 => 0, + 3 => 0, + 2 => 0, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 0, +} +`; + +exports[`ProfilingCache should record changed props/state/context/hooks: CommitDetails commitIndex: 2 1`] = ` +Object { + "changeDescriptions": Map { + 5 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 4 => Object { + "context": false, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 7 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 6 => Object { + "context": Array [], + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 2 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "foo", + ], + "state": Array [], + }, + }, + "duration": 0, + "fiberActualDurations": Map { + 5 => 0, + 4 => 0, + 7 => 0, + 6 => 0, + 3 => 0, + 2 => 0, + 1 => 0, + }, + "fiberSelfDurations": Map { + 5 => 0, + 4 => 0, + 7 => 0, + 6 => 0, + 3 => 0, + 2 => 0, + 1 => 0, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 0, +} +`; + +exports[`ProfilingCache should record changed props/state/context/hooks: CommitDetails commitIndex: 3 1`] = ` +Object { + "changeDescriptions": Map { + 5 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 4 => Object { + "context": false, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 7 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 6 => Object { + "context": Array [], + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 2 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "foo", + "bar", + ], + "state": Array [], + }, + }, + "duration": 0, + "fiberActualDurations": Map { + 5 => 0, + 4 => 0, + 7 => 0, + 6 => 0, + 3 => 0, + 2 => 0, + 1 => 0, + }, + "fiberSelfDurations": Map { + 5 => 0, + 4 => 0, + 7 => 0, + 6 => 0, + 3 => 0, + 2 => 0, + 1 => 0, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 0, +} +`; + +exports[`ProfilingCache should record changed props/state/context/hooks: CommitDetails commitIndex: 4 1`] = ` +Object { + "changeDescriptions": Map { + 5 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 4 => Object { + "context": false, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 7 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 6 => Object { + "context": Array [], + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + 2 => Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "bar", + ], + "state": Array [], + }, + }, + "duration": 0, + "fiberActualDurations": Map { + 5 => 0, + 4 => 0, + 7 => 0, + 6 => 0, + 3 => 0, + 2 => 0, + 1 => 0, + }, + "fiberSelfDurations": Map { + 5 => 0, + 4 => 0, + 7 => 0, + 6 => 0, + 3 => 0, + 2 => 0, + 1 => 0, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 0, +} +`; + +exports[`ProfilingCache should record changed props/state/context/hooks: imported data 1`] = ` +Object { + "dataForRoots": Array [ + Object { + "commitData": Array [ + Object { + "changeDescriptions": Array [ + Array [ + 2, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + Array [ + 4, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + Array [ + 5, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + Array [ + 6, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + Array [ + 7, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + ], + "duration": 0, + "fiberActualDurations": Array [ + Array [ + 1, + 0, + ], + Array [ + 2, + 0, + ], + Array [ + 3, + 0, + ], + Array [ + 4, + 0, + ], + Array [ + 5, + 0, + ], + Array [ + 6, + 0, + ], + Array [ + 7, + 0, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 1, + 0, + ], + Array [ + 2, + 0, + ], + Array [ + 3, + 0, + ], + Array [ + 4, + 0, + ], + Array [ + 5, + 0, + ], + Array [ + 6, + 0, + ], + Array [ + 7, + 0, + ], + ], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 0, + }, + Object { + "changeDescriptions": Array [ + Array [ + 5, + Object { + "context": null, + "didHooksChange": true, + "isFirstMount": false, + "props": Array [ + "count", + ], + "state": null, + }, + ], + Array [ + 4, + Object { + "context": true, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 7, + Object { + "context": null, + "didHooksChange": true, + "isFirstMount": false, + "props": Array [ + "count", + ], + "state": null, + }, + ], + Array [ + 6, + Object { + "context": Array [ + "count", + ], + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 2, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": Array [ + "count", + ], + }, + ], + ], + "duration": 0, + "fiberActualDurations": Array [ + Array [ + 5, + 0, + ], + Array [ + 4, + 0, + ], + Array [ + 7, + 0, + ], + Array [ + 6, + 0, + ], + Array [ + 3, + 0, + ], + Array [ + 2, + 0, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 5, + 0, + ], + Array [ + 4, + 0, + ], + Array [ + 7, + 0, + ], + Array [ + 6, + 0, + ], + Array [ + 3, + 0, + ], + Array [ + 2, + 0, + ], + ], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 0, + }, + Object { + "changeDescriptions": Array [ + Array [ + 5, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 4, + Object { + "context": false, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 7, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 6, + Object { + "context": Array [], + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 2, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "foo", + ], + "state": Array [], + }, + ], + ], + "duration": 0, + "fiberActualDurations": Array [ + Array [ + 5, + 0, + ], + Array [ + 4, + 0, + ], + Array [ + 7, + 0, + ], + Array [ + 6, + 0, + ], + Array [ + 3, + 0, + ], + Array [ + 2, + 0, + ], + Array [ + 1, + 0, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 5, + 0, + ], + Array [ + 4, + 0, + ], + Array [ + 7, + 0, + ], + Array [ + 6, + 0, + ], + Array [ + 3, + 0, + ], + Array [ + 2, + 0, + ], + Array [ + 1, + 0, + ], + ], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 0, + }, + Object { + "changeDescriptions": Array [ + Array [ + 5, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 4, + Object { + "context": false, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 7, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 6, + Object { + "context": Array [], + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 2, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "foo", + "bar", + ], + "state": Array [], + }, + ], + ], + "duration": 0, + "fiberActualDurations": Array [ + Array [ + 5, + 0, + ], + Array [ + 4, + 0, + ], + Array [ + 7, + 0, + ], + Array [ + 6, + 0, + ], + Array [ + 3, + 0, + ], + Array [ + 2, + 0, + ], + Array [ + 1, + 0, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 5, + 0, + ], + Array [ + 4, + 0, + ], + Array [ + 7, + 0, + ], + Array [ + 6, + 0, + ], + Array [ + 3, + 0, + ], + Array [ + 2, + 0, + ], + Array [ + 1, + 0, + ], + ], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 0, + }, + Object { + "changeDescriptions": Array [ + Array [ + 5, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 4, + Object { + "context": false, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 7, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 6, + Object { + "context": Array [], + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 2, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "bar", + ], + "state": Array [], + }, + ], + ], + "duration": 0, + "fiberActualDurations": Array [ + Array [ + 5, + 0, + ], + Array [ + 4, + 0, + ], + Array [ + 7, + 0, + ], + Array [ + 6, + 0, + ], + Array [ + 3, + 0, + ], + Array [ + 2, + 0, + ], + Array [ + 1, + 0, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 5, + 0, + ], + Array [ + 4, + 0, + ], + Array [ + 7, + 0, + ], + Array [ + 6, + 0, + ], + Array [ + 3, + 0, + ], + Array [ + 2, + 0, + ], + Array [ + 1, + 0, + ], + ], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "timestamp": 0, + }, + ], + "displayName": "LegacyContextProvider", + "initialTreeBaseDurations": Array [], + "interactionCommits": Array [], + "interactions": Array [], + "operations": Array [ + Array [ + 1, + 1, + 110, + 21, + 76, + 101, + 103, + 97, + 99, + 121, + 67, + 111, + 110, + 116, + 101, + 120, + 116, + 80, + 114, + 111, + 118, + 105, + 100, + 101, + 114, + 16, + 67, + 111, + 110, + 116, + 101, + 120, + 116, + 46, + 80, + 114, + 111, + 118, + 105, + 100, + 101, + 114, + 21, + 77, + 111, + 100, + 101, + 114, + 110, + 67, + 111, + 110, + 116, + 101, + 120, + 116, + 67, + 111, + 110, + 115, + 117, + 109, + 101, + 114, + 26, + 70, + 117, + 110, + 99, + 116, + 105, + 111, + 110, + 67, + 111, + 109, + 112, + 111, + 110, + 101, + 110, + 116, + 87, + 105, + 116, + 104, + 72, + 111, + 111, + 107, + 115, + 21, + 76, + 101, + 103, + 97, + 99, + 121, + 67, + 111, + 110, + 116, + 101, + 120, + 116, + 67, + 111, + 110, + 115, + 117, + 109, + 101, + 114, + 1, + 1, + 11, + 1, + 1, + 1, + 2, + 1, + 1, + 0, + 1, + 0, + 4, + 2, + 0, + 1, + 3, + 2, + 2, + 2, + 2, + 0, + 4, + 3, + 0, + 1, + 4, + 1, + 3, + 2, + 3, + 0, + 4, + 4, + 0, + 1, + 5, + 5, + 4, + 4, + 4, + 0, + 4, + 5, + 0, + 1, + 6, + 1, + 3, + 2, + 5, + 0, + 4, + 6, + 0, + 1, + 7, + 5, + 6, + 6, + 4, + 0, + 4, + 7, + 0, + ], + Array [ + 1, + 1, + 0, + ], + Array [ + 1, + 1, + 0, + ], + Array [ + 1, + 1, + 0, + ], + Array [ + 1, + 1, + 0, + ], + ], + "rootID": 1, + "snapshots": Array [], + }, + ], + "version": 4, +} +`; + +exports[`ProfilingCache should report every traced interaction: Interactions 1`] = ` +Array [ + Object { + "__count": 1, + "id": 0, + "name": "mount: one child", + "timestamp": 0, + }, + Object { + "__count": 0, + "id": 1, + "name": "update: two children", + "timestamp": 11, + }, +] +`; + +exports[`ProfilingCache should report every traced interaction: imported data 1`] = ` +Object { + "dataForRoots": Array [ + Object { + "commitData": Array [ + Object { + "changeDescriptions": Array [ + Array [ + 2, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + Array [ + 3, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + Array [ + 4, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + ], + "duration": 11, + "fiberActualDurations": Array [ + Array [ + 1, + 11, + ], + Array [ + 2, + 11, + ], + Array [ + 3, + 0, + ], + Array [ + 4, + 1, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 1, + 0, + ], + Array [ + 2, + 10, + ], + Array [ + 3, + 0, + ], + Array [ + 4, + 1, + ], + ], + "interactionIDs": Array [ + 0, + ], + "priorityLevel": "Immediate", + "timestamp": 11, + }, + Object { + "changeDescriptions": Array [ + Array [ + 3, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [], + "state": null, + }, + ], + Array [ + 5, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + ], + Array [ + 2, + Object { + "context": null, + "didHooksChange": false, + "isFirstMount": false, + "props": Array [ + "count", + ], + "state": null, + }, + ], + ], + "duration": 11, + "fiberActualDurations": Array [ + Array [ + 3, + 0, + ], + Array [ + 5, + 1, + ], + Array [ + 2, + 11, + ], + Array [ + 1, + 11, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 3, + 0, + ], + Array [ + 5, + 1, + ], + Array [ + 2, + 10, + ], + Array [ + 1, + 0, + ], + ], + "interactionIDs": Array [ + 1, + ], + "priorityLevel": "Immediate", + "timestamp": 22, + }, + ], + "displayName": "Parent", + "initialTreeBaseDurations": Array [], + "interactionCommits": Array [ + Array [ + 0, + Array [ + 0, + ], + ], + Array [ + 1, + Array [ + 1, + ], + ], + ], + "interactions": Array [ + Array [ + 0, + Object { + "__count": 1, + "id": 0, + "name": "mount: one child", + "timestamp": 0, + }, + ], + Array [ + 1, + Object { + "__count": 0, + "id": 1, + "name": "update: two children", + "timestamp": 11, + }, + ], + ], + "operations": Array [ + Array [ + 1, + 1, + 15, + 6, + 80, + 97, + 114, + 101, + 110, + 116, + 5, + 67, + 104, + 105, + 108, + 100, + 1, + 48, + 1, + 1, + 11, + 1, + 1, + 4, + 1, + 11000, + 1, + 2, + 5, + 1, + 0, + 1, + 0, + 4, + 2, + 11000, + 1, + 3, + 5, + 2, + 2, + 2, + 3, + 4, + 3, + 0, + 1, + 4, + 8, + 2, + 2, + 2, + 0, + 4, + 4, + 1000, + ], + Array [ + 1, + 1, + 8, + 5, + 67, + 104, + 105, + 108, + 100, + 1, + 49, + 1, + 5, + 5, + 2, + 2, + 1, + 2, + 4, + 5, + 1000, + 4, + 2, + 12000, + 3, + 2, + 3, + 3, + 5, + 4, + 4, + 1, + 12000, + ], + ], + "rootID": 1, + "snapshots": Array [], + }, + ], + "version": 4, +} +`; diff --git a/extension/src/__tests__/__snapshots__/profilingCharts-test.js.snap b/extension/src/__tests__/__snapshots__/profilingCharts-test.js.snap new file mode 100644 index 0000000000000..8399be81c6dde --- /dev/null +++ b/extension/src/__tests__/__snapshots__/profilingCharts-test.js.snap @@ -0,0 +1,452 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`profiling charts flamegraph chart should contain valid data: 0: CommitTree 1`] = ` +Object { + "nodes": Map { + 1 => Object { + "children": Array [ + 2, + ], + "displayName": null, + "id": 1, + "key": null, + "parentID": 0, + "treeBaseDuration": 15, + "type": 11, + }, + 2 => Object { + "children": Array [ + 3, + 4, + 5, + ], + "displayName": "Parent", + "id": 2, + "key": null, + "parentID": 1, + "treeBaseDuration": 15, + "type": 5, + }, + 3 => Object { + "children": Array [], + "displayName": "Child", + "id": 3, + "key": "first", + "parentID": 2, + "treeBaseDuration": 3, + "type": 8, + }, + 4 => Object { + "children": Array [], + "displayName": "Child", + "id": 4, + "key": "second", + "parentID": 2, + "treeBaseDuration": 2, + "type": 8, + }, + 5 => Object { + "children": Array [], + "displayName": "Child", + "id": 5, + "key": "third", + "parentID": 2, + "treeBaseDuration": 0, + "type": 8, + }, + }, + "rootID": 1, +} +`; + +exports[`profiling charts flamegraph chart should contain valid data: 0: FlamegraphChartData 1`] = ` +Object { + "baseDuration": 15, + "depth": 2, + "idToDepthMap": Map { + 2 => 1, + 5 => 2, + 4 => 2, + 3 => 2, + }, + "maxSelfDuration": 10, + "renderPathNodes": Set { + 1, + 2, + }, + "rows": Array [ + Array [ + Object { + "actualDuration": 15, + "didRender": true, + "id": 2, + "label": "Parent (10ms of 15ms)", + "name": "Parent", + "offset": 0, + "selfDuration": 10, + "treeBaseDuration": 15, + }, + ], + Array [ + Object { + "actualDuration": 0, + "didRender": true, + "id": 5, + "label": "Child (Memo) key=\\"third\\" (<0.1ms of <0.1ms)", + "name": "Child", + "offset": 15, + "selfDuration": 0, + "treeBaseDuration": 0, + }, + Object { + "actualDuration": 2, + "didRender": true, + "id": 4, + "label": "Child (Memo) key=\\"second\\" (2ms of 2ms)", + "name": "Child", + "offset": 13, + "selfDuration": 2, + "treeBaseDuration": 2, + }, + Object { + "actualDuration": 3, + "didRender": true, + "id": 3, + "label": "Child (Memo) key=\\"first\\" (3ms of 3ms)", + "name": "Child", + "offset": 10, + "selfDuration": 3, + "treeBaseDuration": 3, + }, + ], + ], +} +`; + +exports[`profiling charts flamegraph chart should contain valid data: 1: CommitTree 1`] = ` +Object { + "nodes": Map { + 1 => Object { + "children": Array [ + 2, + ], + "displayName": null, + "id": 1, + "key": null, + "parentID": 0, + "treeBaseDuration": 15, + "type": 11, + }, + 2 => Object { + "children": Array [ + 3, + 4, + 5, + ], + "displayName": "Parent", + "id": 2, + "key": null, + "parentID": 1, + "treeBaseDuration": 15, + "type": 5, + }, + 3 => Object { + "children": Array [], + "displayName": "Child", + "id": 3, + "key": "first", + "parentID": 2, + "treeBaseDuration": 3, + "type": 8, + }, + 4 => Object { + "children": Array [], + "displayName": "Child", + "id": 4, + "key": "second", + "parentID": 2, + "treeBaseDuration": 2, + "type": 8, + }, + 5 => Object { + "children": Array [], + "displayName": "Child", + "id": 5, + "key": "third", + "parentID": 2, + "treeBaseDuration": 0, + "type": 8, + }, + }, + "rootID": 1, +} +`; + +exports[`profiling charts flamegraph chart should contain valid data: 1: FlamegraphChartData 1`] = ` +Object { + "baseDuration": 15, + "depth": 2, + "idToDepthMap": Map { + 2 => 1, + 5 => 2, + 4 => 2, + 3 => 2, + }, + "maxSelfDuration": 10, + "renderPathNodes": Set { + 1, + }, + "rows": Array [ + Array [ + Object { + "actualDuration": 10, + "didRender": true, + "id": 2, + "label": "Parent (10ms of 10ms)", + "name": "Parent", + "offset": 0, + "selfDuration": 10, + "treeBaseDuration": 15, + }, + ], + Array [ + Object { + "actualDuration": 0, + "didRender": false, + "id": 5, + "label": "Child (Memo) key=\\"third\\"", + "name": "Child", + "offset": 15, + "selfDuration": 0, + "treeBaseDuration": 0, + }, + Object { + "actualDuration": 0, + "didRender": false, + "id": 4, + "label": "Child (Memo) key=\\"second\\"", + "name": "Child", + "offset": 13, + "selfDuration": 0, + "treeBaseDuration": 2, + }, + Object { + "actualDuration": 0, + "didRender": false, + "id": 3, + "label": "Child (Memo) key=\\"first\\"", + "name": "Child", + "offset": 10, + "selfDuration": 0, + "treeBaseDuration": 3, + }, + ], + ], +} +`; + +exports[`profiling charts interactions should contain valid data: Interactions 1`] = ` +Object { + "interactions": Array [ + Object { + "__count": 1, + "id": 0, + "name": "mount", + "timestamp": 0, + }, + Object { + "__count": 0, + "id": 1, + "name": "update", + "timestamp": 15, + }, + ], + "lastInteractionTime": 25, + "maxCommitDuration": 15, +} +`; + +exports[`profiling charts interactions should contain valid data: Interactions 2`] = ` +Object { + "interactions": Array [ + Object { + "__count": 1, + "id": 0, + "name": "mount", + "timestamp": 0, + }, + Object { + "__count": 0, + "id": 1, + "name": "update", + "timestamp": 15, + }, + ], + "lastInteractionTime": 25, + "maxCommitDuration": 15, +} +`; + +exports[`profiling charts ranked chart should contain valid data: 0: CommitTree 1`] = ` +Object { + "nodes": Map { + 1 => Object { + "children": Array [ + 2, + ], + "displayName": null, + "id": 1, + "key": null, + "parentID": 0, + "treeBaseDuration": 15, + "type": 11, + }, + 2 => Object { + "children": Array [ + 3, + 4, + 5, + ], + "displayName": "Parent", + "id": 2, + "key": null, + "parentID": 1, + "treeBaseDuration": 15, + "type": 5, + }, + 3 => Object { + "children": Array [], + "displayName": "Child", + "id": 3, + "key": "first", + "parentID": 2, + "treeBaseDuration": 3, + "type": 8, + }, + 4 => Object { + "children": Array [], + "displayName": "Child", + "id": 4, + "key": "second", + "parentID": 2, + "treeBaseDuration": 2, + "type": 8, + }, + 5 => Object { + "children": Array [], + "displayName": "Child", + "id": 5, + "key": "third", + "parentID": 2, + "treeBaseDuration": 0, + "type": 8, + }, + }, + "rootID": 1, +} +`; + +exports[`profiling charts ranked chart should contain valid data: 0: RankedChartData 1`] = ` +Object { + "maxValue": 10, + "nodes": Array [ + Object { + "id": 2, + "label": "Parent (10ms)", + "name": "Parent", + "value": 10, + }, + Object { + "id": 3, + "label": "Child (Memo) key=\\"first\\" (3ms)", + "name": "Child", + "value": 3, + }, + Object { + "id": 4, + "label": "Child (Memo) key=\\"second\\" (2ms)", + "name": "Child", + "value": 2, + }, + Object { + "id": 5, + "label": "Child (Memo) key=\\"third\\" (<0.1ms)", + "name": "Child", + "value": 0, + }, + ], +} +`; + +exports[`profiling charts ranked chart should contain valid data: 1: CommitTree 1`] = ` +Object { + "nodes": Map { + 1 => Object { + "children": Array [ + 2, + ], + "displayName": null, + "id": 1, + "key": null, + "parentID": 0, + "treeBaseDuration": 15, + "type": 11, + }, + 2 => Object { + "children": Array [ + 3, + 4, + 5, + ], + "displayName": "Parent", + "id": 2, + "key": null, + "parentID": 1, + "treeBaseDuration": 15, + "type": 5, + }, + 3 => Object { + "children": Array [], + "displayName": "Child", + "id": 3, + "key": "first", + "parentID": 2, + "treeBaseDuration": 3, + "type": 8, + }, + 4 => Object { + "children": Array [], + "displayName": "Child", + "id": 4, + "key": "second", + "parentID": 2, + "treeBaseDuration": 2, + "type": 8, + }, + 5 => Object { + "children": Array [], + "displayName": "Child", + "id": 5, + "key": "third", + "parentID": 2, + "treeBaseDuration": 0, + "type": 8, + }, + }, + "rootID": 1, +} +`; + +exports[`profiling charts ranked chart should contain valid data: 1: RankedChartData 1`] = ` +Object { + "maxValue": 10, + "nodes": Array [ + Object { + "id": 2, + "label": "Parent (10ms)", + "name": "Parent", + "value": 10, + }, + ], +} +`; diff --git a/extension/src/__tests__/__snapshots__/profilingCommitTreeBuilder-test.js.snap b/extension/src/__tests__/__snapshots__/profilingCommitTreeBuilder-test.js.snap new file mode 100644 index 0000000000000..6f8573b08e79f --- /dev/null +++ b/extension/src/__tests__/__snapshots__/profilingCommitTreeBuilder-test.js.snap @@ -0,0 +1,176 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`commit tree should be able to rebuild the store tree for each commit: 0: CommitTree 1`] = ` +Object { + "nodes": Map { + 1 => Object { + "children": Array [ + 2, + ], + "displayName": null, + "id": 1, + "key": null, + "parentID": 0, + "treeBaseDuration": 12, + "type": 11, + }, + 2 => Object { + "children": Array [ + 3, + ], + "displayName": "Parent", + "id": 2, + "key": null, + "parentID": 1, + "treeBaseDuration": 12, + "type": 5, + }, + 3 => Object { + "children": Array [], + "displayName": "Child", + "id": 3, + "key": "0", + "parentID": 2, + "treeBaseDuration": 2, + "type": 8, + }, + }, + "rootID": 1, +} +`; + +exports[`commit tree should be able to rebuild the store tree for each commit: 1: CommitTree 1`] = ` +Object { + "nodes": Map { + 1 => Object { + "children": Array [ + 2, + ], + "displayName": null, + "id": 1, + "key": null, + "parentID": 0, + "treeBaseDuration": 16, + "type": 11, + }, + 2 => Object { + "children": Array [ + 3, + 4, + 5, + ], + "displayName": "Parent", + "id": 2, + "key": null, + "parentID": 1, + "treeBaseDuration": 16, + "type": 5, + }, + 3 => Object { + "children": Array [], + "displayName": "Child", + "id": 3, + "key": "0", + "parentID": 2, + "treeBaseDuration": 2, + "type": 8, + }, + 4 => Object { + "children": Array [], + "displayName": "Child", + "id": 4, + "key": "1", + "parentID": 2, + "treeBaseDuration": 2, + "type": 8, + }, + 5 => Object { + "children": Array [], + "displayName": "Child", + "id": 5, + "key": "2", + "parentID": 2, + "treeBaseDuration": 2, + "type": 8, + }, + }, + "rootID": 1, +} +`; + +exports[`commit tree should be able to rebuild the store tree for each commit: 2: CommitTree 1`] = ` +Object { + "nodes": Map { + 1 => Object { + "children": Array [ + 2, + ], + "displayName": null, + "id": 1, + "key": null, + "parentID": 0, + "treeBaseDuration": 14, + "type": 11, + }, + 2 => Object { + "children": Array [ + 3, + 4, + ], + "displayName": "Parent", + "id": 2, + "key": null, + "parentID": 1, + "treeBaseDuration": 14, + "type": 5, + }, + 3 => Object { + "children": Array [], + "displayName": "Child", + "id": 3, + "key": "0", + "parentID": 2, + "treeBaseDuration": 2, + "type": 8, + }, + 4 => Object { + "children": Array [], + "displayName": "Child", + "id": 4, + "key": "1", + "parentID": 2, + "treeBaseDuration": 2, + "type": 8, + }, + }, + "rootID": 1, +} +`; + +exports[`commit tree should be able to rebuild the store tree for each commit: 3: CommitTree 1`] = ` +Object { + "nodes": Map { + 1 => Object { + "children": Array [ + 2, + ], + "displayName": null, + "id": 1, + "key": null, + "parentID": 0, + "treeBaseDuration": 10, + "type": 11, + }, + 2 => Object { + "children": Array [], + "displayName": "Parent", + "id": 2, + "key": null, + "parentID": 1, + "treeBaseDuration": 10, + "type": 5, + }, + }, + "rootID": 1, +} +`; diff --git a/extension/src/__tests__/__snapshots__/store-test.js.snap b/extension/src/__tests__/__snapshots__/store-test.js.snap new file mode 100644 index 0000000000000..39bddd9b583ab --- /dev/null +++ b/extension/src/__tests__/__snapshots__/store-test.js.snap @@ -0,0 +1,590 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Store collapseNodesByDefault:false should display Suspense nodes properly in various states: 1: loading 1`] = ` +[root] + ▾ + + ▾ + +`; + +exports[`Store collapseNodesByDefault:false should display Suspense nodes properly in various states: 2: resolved 1`] = ` +[root] + ▾ + + ▾ + +`; + +exports[`Store collapseNodesByDefault:false should filter DOM nodes from the store tree: 1: mount 1`] = ` +[root] + ▾ + ▾ + + ▾ + +`; + +exports[`Store collapseNodesByDefault:false should support collapsing parts of the tree: 1: mount 1`] = ` +[root] + ▾ + ▾ + + + ▾ + + +`; + +exports[`Store collapseNodesByDefault:false should support collapsing parts of the tree: 2: collapse first Parent 1`] = ` +[root] + ▾ + ▸ + ▾ + + +`; + +exports[`Store collapseNodesByDefault:false should support collapsing parts of the tree: 3: collapse second Parent 1`] = ` +[root] + ▾ + ▸ + ▸ +`; + +exports[`Store collapseNodesByDefault:false should support collapsing parts of the tree: 4: expand first Parent 1`] = ` +[root] + ▾ + ▾ + + + ▸ +`; + +exports[`Store collapseNodesByDefault:false should support collapsing parts of the tree: 5: collapse Grandparent 1`] = ` +[root] + ▸ +`; + +exports[`Store collapseNodesByDefault:false should support collapsing parts of the tree: 6: expand Grandparent 1`] = ` +[root] + ▾ + ▾ + + + ▸ +`; + +exports[`Store collapseNodesByDefault:false should support mount and update operations for multiple roots: 1: mount 1`] = ` +[root] + ▾ + + + +[root] + ▾ + + +`; + +exports[`Store collapseNodesByDefault:false should support mount and update operations for multiple roots: 2: update 1`] = ` +[root] + ▾ + + + + +[root] + ▾ + +`; + +exports[`Store collapseNodesByDefault:false should support mount and update operations for multiple roots: 3: unmount B 1`] = ` +[root] + ▾ + + + + +`; + +exports[`Store collapseNodesByDefault:false should support mount and update operations for multiple roots: 4: unmount A 1`] = ``; + +exports[`Store collapseNodesByDefault:false should support mount and update operations: 1: mount 1`] = ` +[root] + ▾ + ▾ + + + + + ▾ + + + + +`; + +exports[`Store collapseNodesByDefault:false should support mount and update operations: 2: update 1`] = ` +[root] + ▾ + ▾ + + + ▾ + + +`; + +exports[`Store collapseNodesByDefault:false should support mount and update operations: 3: unmount 1`] = ``; + +exports[`Store collapseNodesByDefault:false should support nested Suspense nodes: 1: third child is suspended 1`] = ` +[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + +`; + +exports[`Store collapseNodesByDefault:false should support nested Suspense nodes: 2: first and third child are suspended 1`] = ` +[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + +`; + +exports[`Store collapseNodesByDefault:false should support nested Suspense nodes: 3: second and third child are suspended 1`] = ` +[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + +`; + +exports[`Store collapseNodesByDefault:false should support nested Suspense nodes: 4: first and third child are suspended 1`] = ` +[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + +`; + +exports[`Store collapseNodesByDefault:false should support nested Suspense nodes: 5: parent is suspended 1`] = ` +[root] + ▾ + + ▾ + +`; + +exports[`Store collapseNodesByDefault:false should support nested Suspense nodes: 6: all children are suspended 1`] = ` +[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + +`; + +exports[`Store collapseNodesByDefault:false should support nested Suspense nodes: 7: only third child is suspended 1`] = ` +[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + +`; + +exports[`Store collapseNodesByDefault:false should support nested Suspense nodes: 8: first and third child are suspended 1`] = ` +[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + +`; + +exports[`Store collapseNodesByDefault:false should support nested Suspense nodes: 9: parent is suspended 1`] = ` +[root] + ▾ + + ▾ + +`; + +exports[`Store collapseNodesByDefault:false should support nested Suspense nodes: 10: parent is suspended 1`] = ` +[root] + ▾ + + ▾ + +`; + +exports[`Store collapseNodesByDefault:false should support nested Suspense nodes: 11: all children are suspended 1`] = ` +[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + +`; + +exports[`Store collapseNodesByDefault:false should support nested Suspense nodes: 12: all children are suspended 1`] = ` +[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + +`; + +exports[`Store collapseNodesByDefault:false should support nested Suspense nodes: 13: third child is suspended 1`] = ` +[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + +`; + +exports[`Store collapseNodesByDefault:false should support reordering of children: 1: mount 1`] = ` +[root] + ▾ + ▾ + + ▾ + + +`; + +exports[`Store collapseNodesByDefault:false should support reordering of children: 3: reorder children 1`] = ` +[root] + ▾ + ▾ + + + ▾ + +`; + +exports[`Store collapseNodesByDefault:false should support reordering of children: 4: collapse root 1`] = ` +[root] + ▸ +`; + +exports[`Store collapseNodesByDefault:false should support reordering of children: 5: expand root 1`] = ` +[root] + ▾ + ▾ + + + ▾ + +`; + +exports[`Store collapseNodesByDefault:true should display Suspense nodes properly in various states: 1: loading 1`] = ` +[root] + ▸ +`; + +exports[`Store collapseNodesByDefault:true should display Suspense nodes properly in various states: 2: expand Wrapper and Suspense 1`] = ` +[root] + ▾ + + ▾ + +`; + +exports[`Store collapseNodesByDefault:true should display Suspense nodes properly in various states: 2: resolved 1`] = ` +[root] + ▾ + + ▾ + +`; + +exports[`Store collapseNodesByDefault:true should filter DOM nodes from the store tree: 1: mount 1`] = ` +[root] + ▸ +`; + +exports[`Store collapseNodesByDefault:true should filter DOM nodes from the store tree: 2: expand Grandparent 1`] = ` +[root] + ▾ + ▸ + ▸ +`; + +exports[`Store collapseNodesByDefault:true should filter DOM nodes from the store tree: 3: expand Parent 1`] = ` +[root] + ▾ + ▾ + + ▸ +`; + +exports[`Store collapseNodesByDefault:true should not add new nodes when suspense is toggled: 1: mount 1`] = ` +[root] + ▸ +`; + +exports[`Store collapseNodesByDefault:true should not add new nodes when suspense is toggled: 2: expand tree 1`] = ` +[root] + ▾ + ▾ + ▸ +`; + +exports[`Store collapseNodesByDefault:true should not add new nodes when suspense is toggled: 3: toggle fallback on 1`] = ` +[root] + ▾ + ▾ + +`; + +exports[`Store collapseNodesByDefault:true should not add new nodes when suspense is toggled: 4: toggle fallback on 1`] = ` +[root] + ▾ + ▾ + ▸ +`; + +exports[`Store collapseNodesByDefault:true should support expanding deep parts of the tree: 1: mount 1`] = ` +[root] + ▸ +`; + +exports[`Store collapseNodesByDefault:true should support expanding deep parts of the tree: 2: expand deepest node 1`] = ` +[root] + ▾ + ▾ + ▾ + ▾ + +`; + +exports[`Store collapseNodesByDefault:true should support expanding deep parts of the tree: 3: collapse root 1`] = ` +[root] + ▸ +`; + +exports[`Store collapseNodesByDefault:true should support expanding deep parts of the tree: 4: expand root 1`] = ` +[root] + ▾ + ▾ + ▾ + ▾ + +`; + +exports[`Store collapseNodesByDefault:true should support expanding deep parts of the tree: 5: collapse middle node 1`] = ` +[root] + ▾ + ▸ +`; + +exports[`Store collapseNodesByDefault:true should support expanding deep parts of the tree: 6: expand middle node 1`] = ` +[root] + ▾ + ▾ + ▾ + ▾ + +`; + +exports[`Store collapseNodesByDefault:true should support expanding parts of the tree: 1: mount 1`] = ` +[root] + ▸ +`; + +exports[`Store collapseNodesByDefault:true should support expanding parts of the tree: 2: expand Grandparent 1`] = ` +[root] + ▾ + ▸ + ▸ +`; + +exports[`Store collapseNodesByDefault:true should support expanding parts of the tree: 3: expand first Parent 1`] = ` +[root] + ▾ + ▾ + + + ▸ +`; + +exports[`Store collapseNodesByDefault:true should support expanding parts of the tree: 4: expand second Parent 1`] = ` +[root] + ▾ + ▾ + + + ▾ + + +`; + +exports[`Store collapseNodesByDefault:true should support expanding parts of the tree: 5: collapse first Parent 1`] = ` +[root] + ▾ + ▸ + ▾ + + +`; + +exports[`Store collapseNodesByDefault:true should support expanding parts of the tree: 6: collapse second Parent 1`] = ` +[root] + ▾ + ▸ + ▸ +`; + +exports[`Store collapseNodesByDefault:true should support expanding parts of the tree: 7: collapse Grandparent 1`] = ` +[root] + ▸ +`; + +exports[`Store collapseNodesByDefault:true should support mount and update operations for multiple roots: 1: mount 1`] = ` +[root] + ▸ +[root] + ▸ +`; + +exports[`Store collapseNodesByDefault:true should support mount and update operations for multiple roots: 2: update 1`] = ` +[root] + ▸ +[root] + ▸ +`; + +exports[`Store collapseNodesByDefault:true should support mount and update operations for multiple roots: 3: unmount B 1`] = ` +[root] + ▸ +`; + +exports[`Store collapseNodesByDefault:true should support mount and update operations for multiple roots: 4: unmount A 1`] = ``; + +exports[`Store collapseNodesByDefault:true should support mount and update operations: 1: mount 1`] = ` +[root] + ▸ + ▸ +`; + +exports[`Store collapseNodesByDefault:true should support mount and update operations: 2: update 1`] = ` +[root] + ▸ + ▸ +`; + +exports[`Store collapseNodesByDefault:true should support mount and update operations: 3: unmount 1`] = ``; + +exports[`Store collapseNodesByDefault:true should support reordering of children: 1: mount 1`] = ` +[root] + ▸ +`; + +exports[`Store collapseNodesByDefault:true should support reordering of children: 3: reorder children 1`] = ` +[root] + ▸ +`; + +exports[`Store collapseNodesByDefault:true should support reordering of children: 4: expand root 1`] = ` +[root] + ▾ + ▸ + ▸ +`; + +exports[`Store collapseNodesByDefault:true should support reordering of children: 5: expand leaves 1`] = ` +[root] + ▾ + ▾ + + + ▾ + +`; + +exports[`Store collapseNodesByDefault:true should support reordering of children: 6: collapse root 1`] = ` +[root] + ▸ +`; + +exports[`Store should not allow a root node to be collapsed: 1: mount 1`] = ` +[root] + +`; + +exports[`Store should properly handle a root with no visible nodes: 1: mount 1`] = ` +[root] + +`; + +exports[`Store should properly handle a root with no visible nodes: 2: add host nodes 1`] = `[root]`; diff --git a/extension/src/__tests__/__snapshots__/storeComponentFilters-test.js.snap b/extension/src/__tests__/__snapshots__/storeComponentFilters-test.js.snap new file mode 100644 index 0000000000000..884b6cbcec85d --- /dev/null +++ b/extension/src/__tests__/__snapshots__/storeComponentFilters-test.js.snap @@ -0,0 +1,120 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Store component filters should filter HOCs: 1: mount 1`] = ` +[root] + ▾ [Bar][Foo] + ▾ [Foo] + ▾ +
+`; + +exports[`Store component filters should filter HOCs: 2: hide all HOCs 1`] = ` +[root] + ▾ +
+`; + +exports[`Store component filters should filter HOCs: 3: disable HOC filter 1`] = ` +[root] + ▾ [Bar][Foo] + ▾ [Foo] + ▾ +
+`; + +exports[`Store component filters should filter by display name: 1: mount 1`] = ` +[root] + ▾ + + ▾ + + ▾ + +`; + +exports[`Store component filters should filter by display name: 2: filter "Foo" 1`] = ` +[root] + + ▾ + + ▾ + +`; + +exports[`Store component filters should filter by display name: 3: filter "Ba" 1`] = ` +[root] + ▾ + + + +`; + +exports[`Store component filters should filter by display name: 4: filter "B.z" 1`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`Store component filters should filter by path: 1: mount 1`] = ` +[root] + ▾ +
+`; + +exports[`Store component filters should filter by path: 2: hide all components declared within this test filed 1`] = `[root]`; + +exports[`Store component filters should filter by path: 3: hide components in a made up fake path 1`] = ` +[root] + ▾ +
+`; + +exports[`Store component filters should ignore invalid ElementTypeRoot filter: 1: mount 1`] = ` +[root] + ▾ +
+`; + +exports[`Store component filters should ignore invalid ElementTypeRoot filter: 2: add invalid filter 1`] = ` +[root] + ▾ +
+`; + +exports[`Store component filters should support filtering by element type: 1: mount 1`] = ` +[root] + ▾ + ▾
+ ▾ +
+`; + +exports[`Store component filters should support filtering by element type: 2: hide host components 1`] = ` +[root] + ▾ + +`; + +exports[`Store component filters should support filtering by element type: 3: hide class components 1`] = ` +[root] + ▾
+ ▾ +
+`; + +exports[`Store component filters should support filtering by element type: 4: hide class and function components 1`] = ` +[root] + ▾
+
+`; + +exports[`Store component filters should support filtering by element type: 5: disable all filters 1`] = ` +[root] + ▾ + ▾
+ ▾ +
+`; diff --git a/extension/src/__tests__/__snapshots__/storeOwners-test.js.snap b/extension/src/__tests__/__snapshots__/storeOwners-test.js.snap new file mode 100644 index 0000000000000..28069711fd680 --- /dev/null +++ b/extension/src/__tests__/__snapshots__/storeOwners-test.js.snap @@ -0,0 +1,121 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Store owners list should drill through interleaved intermediate components: 1: mount 1`] = ` +[root] + ▾ + ▾ + + ▾ + + +`; + +exports[`Store owners list should drill through interleaved intermediate components: 2: components owned by 1`] = ` +" ▾ + ▾ + + " +`; + +exports[`Store owners list should drill through interleaved intermediate components: 3: components owned by 1`] = ` +" ▾ + + ▾ " +`; + +exports[`Store owners list should drill through intermediate components: 1: mount 1`] = ` +[root] + ▾ + ▾ + ▾ + +`; + +exports[`Store owners list should drill through intermediate components: 2: components owned by 1`] = ` +" ▾ + ▾ + " +`; + +exports[`Store owners list should drill through intermediate components: 3: components owned by 1`] = ` +" ▾ + ▾ " +`; + +exports[`Store owners list should show the proper owners list order and contents after insertions and deletions: 1: mount 1`] = ` +[root] + ▾ + ▾ + ▾ + +`; + +exports[`Store owners list should show the proper owners list order and contents after insertions and deletions: 2: components owned by 1`] = ` +" ▾ + ▾ + " +`; + +exports[`Store owners list should show the proper owners list order and contents after insertions and deletions: 3: update to add direct 1`] = ` +[root] + ▾ + + ▾ + ▾ + +`; + +exports[`Store owners list should show the proper owners list order and contents after insertions and deletions: 4: components owned by 1`] = ` +" ▾ + + ▾ + " +`; + +exports[`Store owners list should show the proper owners list order and contents after insertions and deletions: 5: update to remove indirect 1`] = ` +[root] + ▾ + +`; + +exports[`Store owners list should show the proper owners list order and contents after insertions and deletions: 6: components owned by 1`] = ` +" ▾ + " +`; + +exports[`Store owners list should show the proper owners list order and contents after insertions and deletions: 7: update to remove both 1`] = ` +[root] + +`; + +exports[`Store owners list should show the proper owners list order and contents after insertions and deletions: 8: components owned by 1`] = `" "`; + +exports[`Store owners list should show the proper owners list ordering after reordered children: 1: mount (ascending) 1`] = ` +[root] + ▾ + + + +`; + +exports[`Store owners list should show the proper owners list ordering after reordered children: 2: components owned by 1`] = ` +" ▾ + + + " +`; + +exports[`Store owners list should show the proper owners list ordering after reordered children: 3: update (descending) 1`] = ` +[root] + ▾ + + + +`; + +exports[`Store owners list should show the proper owners list ordering after reordered children: 4: components owned by 1`] = ` +" ▾ + + + " +`; diff --git a/extension/src/__tests__/__snapshots__/storeStressSync-test.js.snap b/extension/src/__tests__/__snapshots__/storeStressSync-test.js.snap new file mode 100644 index 0000000000000..801ad148a09aa --- /dev/null +++ b/extension/src/__tests__/__snapshots__/storeStressSync-test.js.snap @@ -0,0 +1,493 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense (Sync Mode) 1`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense (Sync Mode) 2`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense (Sync Mode) 3`] = ` +[root] + ▾ + + ▾ + + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense (Sync Mode) 4`] = ` +[root] + ▾ + + ▾ + + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense (Sync Mode) 5`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense (Sync Mode) 6`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense (Sync Mode) 7`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense (Sync Mode) 8`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense (Sync Mode) 9`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense (Sync Mode) 10`] = ` +[root] + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense (Sync Mode) 11`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense (Sync Mode) 12`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 1`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 2`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 3`] = ` +[root] + ▾ + + ▾ + ▾ + + + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 4`] = ` +[root] + ▾ + + ▾ + ▾ + + + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 5`] = ` +[root] + ▾ + + ▾ + ▾ + + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 6`] = ` +[root] + ▾ + + ▾ + ▾ + + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 7`] = ` +[root] + ▾ + + ▾ + ▾ + + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 8`] = ` +[root] + ▾ + + ▾ + ▾ + + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 9`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 10`] = ` +[root] + ▾ + + ▾ + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 11`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 12`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 13`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 14`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 15`] = ` +[root] + ▾ + + ▾ + + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 16`] = ` +[root] + ▾ + + ▾ + + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 17`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 18`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 19`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 20`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 21`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 22`] = ` +[root] + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 23`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 24`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test with different tree operations (Sync Mode): 1: abcde 1`] = ` +[root] + ▾ + + + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test with different tree operations (Sync Mode): 2: abxde 1`] = ` +[root] + ▾ + + + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle stress test with reordering (Sync Mode) 1`] = ` +[root] + ▾ + +`; + +exports[`StoreStress (Sync Mode) should handle stress test with reordering (Sync Mode) 2`] = ` +[root] + ▾ + +`; + +exports[`StoreStress (Sync Mode) should handle stress test with reordering (Sync Mode) 3`] = ` +[root] + ▾ + +`; + +exports[`StoreStress (Sync Mode) should handle stress test with reordering (Sync Mode) 4`] = ` +[root] + ▾ + +`; + +exports[`StoreStress (Sync Mode) should handle stress test with reordering (Sync Mode) 5`] = ` +[root] + ▾ + +`; + +exports[`StoreStress (Sync Mode) should handle stress test with reordering (Sync Mode) 6`] = ` +[root] + ▾ + +`; + +exports[`StoreStress (Sync Mode) should handle stress test with reordering (Sync Mode) 7`] = ` +[root] + ▾ + +`; + +exports[`StoreStress (Sync Mode) should handle stress test with reordering (Sync Mode) 8`] = ` +[root] + ▾ + +`; + +exports[`StoreStress (Sync Mode) should handle stress test with reordering (Sync Mode) 9`] = ` +[root] + ▾ + +`; + +exports[`StoreStress (Sync Mode) should handle stress test with reordering (Sync Mode) 10`] = ` +[root] + ▾ + +`; + +exports[`StoreStress (Sync Mode) should handle stress test with reordering (Sync Mode) 11`] = ` +[root] + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle stress test with reordering (Sync Mode) 12`] = ` +[root] + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle stress test with reordering (Sync Mode) 13`] = ` +[root] + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle stress test with reordering (Sync Mode) 14`] = ` +[root] + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle stress test with reordering (Sync Mode) 15`] = ` +[root] + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle stress test with reordering (Sync Mode) 16`] = ` +[root] + ▾ + + +`; diff --git a/extension/src/__tests__/__snapshots__/storeStressTestConcurrent-test.js.snap b/extension/src/__tests__/__snapshots__/storeStressTestConcurrent-test.js.snap new file mode 100644 index 0000000000000..9527d9f6025de --- /dev/null +++ b/extension/src/__tests__/__snapshots__/storeStressTestConcurrent-test.js.snap @@ -0,0 +1,493 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StoreStressConcurrent should handle a stress test for Suspense (Concurrent Mode) 1`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense (Concurrent Mode) 2`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense (Concurrent Mode) 3`] = ` +[root] + ▾ + + ▾ + + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense (Concurrent Mode) 4`] = ` +[root] + ▾ + + ▾ + + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense (Concurrent Mode) 5`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense (Concurrent Mode) 6`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense (Concurrent Mode) 7`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense (Concurrent Mode) 8`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense (Concurrent Mode) 9`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense (Concurrent Mode) 10`] = ` +[root] + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense (Concurrent Mode) 11`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense (Concurrent Mode) 12`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 1`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 2`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 3`] = ` +[root] + ▾ + + ▾ + ▾ + + + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 4`] = ` +[root] + ▾ + + ▾ + ▾ + + + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 5`] = ` +[root] + ▾ + + ▾ + ▾ + + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 6`] = ` +[root] + ▾ + + ▾ + ▾ + + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 7`] = ` +[root] + ▾ + + ▾ + ▾ + + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 8`] = ` +[root] + ▾ + + ▾ + ▾ + + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 9`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 10`] = ` +[root] + ▾ + + ▾ + ▾ + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 11`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 12`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 13`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 14`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 15`] = ` +[root] + ▾ + + ▾ + + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 16`] = ` +[root] + ▾ + + ▾ + + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 17`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 18`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 19`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 20`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 21`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 22`] = ` +[root] + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 23`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 24`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStressConcurrent should handle a stress test with different tree operations (Concurrent Mode): 1: abcde 1`] = ` +[root] + ▾ + + + + + +`; + +exports[`StoreStressConcurrent should handle a stress test with different tree operations (Concurrent Mode): 2: abxde 1`] = ` +[root] + ▾ + + + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle stress test with reordering (Concurrent Mode) 1`] = ` +[root] + ▾ + +`; + +exports[`StoreStressConcurrent should handle stress test with reordering (Concurrent Mode) 2`] = ` +[root] + ▾ + +`; + +exports[`StoreStressConcurrent should handle stress test with reordering (Concurrent Mode) 3`] = ` +[root] + ▾ + +`; + +exports[`StoreStressConcurrent should handle stress test with reordering (Concurrent Mode) 4`] = ` +[root] + ▾ + +`; + +exports[`StoreStressConcurrent should handle stress test with reordering (Concurrent Mode) 5`] = ` +[root] + ▾ + +`; + +exports[`StoreStressConcurrent should handle stress test with reordering (Concurrent Mode) 6`] = ` +[root] + ▾ + +`; + +exports[`StoreStressConcurrent should handle stress test with reordering (Concurrent Mode) 7`] = ` +[root] + ▾ + +`; + +exports[`StoreStressConcurrent should handle stress test with reordering (Concurrent Mode) 8`] = ` +[root] + ▾ + +`; + +exports[`StoreStressConcurrent should handle stress test with reordering (Concurrent Mode) 9`] = ` +[root] + ▾ + +`; + +exports[`StoreStressConcurrent should handle stress test with reordering (Concurrent Mode) 10`] = ` +[root] + ▾ + +`; + +exports[`StoreStressConcurrent should handle stress test with reordering (Concurrent Mode) 11`] = ` +[root] + ▾ + + +`; + +exports[`StoreStressConcurrent should handle stress test with reordering (Concurrent Mode) 12`] = ` +[root] + ▾ + + +`; + +exports[`StoreStressConcurrent should handle stress test with reordering (Concurrent Mode) 13`] = ` +[root] + ▾ + + +`; + +exports[`StoreStressConcurrent should handle stress test with reordering (Concurrent Mode) 14`] = ` +[root] + ▾ + + +`; + +exports[`StoreStressConcurrent should handle stress test with reordering (Concurrent Mode) 15`] = ` +[root] + ▾ + + +`; + +exports[`StoreStressConcurrent should handle stress test with reordering (Concurrent Mode) 16`] = ` +[root] + ▾ + + +`; diff --git a/extension/src/__tests__/__snapshots__/treeContext-test.js.snap b/extension/src/__tests__/__snapshots__/treeContext-test.js.snap new file mode 100644 index 0000000000000..33230c69aa6b1 --- /dev/null +++ b/extension/src/__tests__/__snapshots__/treeContext-test.js.snap @@ -0,0 +1,1204 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TreeListContext owners state should exit the owners list if an element outside the list is selected: 0: mount 1`] = ` +[root] + ▾ + ▾ + ▾ + ▾ + +`; + +exports[`TreeListContext owners state should exit the owners list if an element outside the list is selected: 1: initial state 1`] = ` +Object { + "inspectedElementID": null, + "numElements": 5, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": null, + "selectedElementIndex": null, +} +`; + +exports[`TreeListContext owners state should exit the owners list if an element outside the list is selected: 2: child owners tree 1`] = ` +Object { + "inspectedElementID": 4, + "numElements": 3, + "ownerFlatTree": Array [ + Object { + "children": Array [ + 5, + ], + "depth": 0, + "displayName": "Child", + "hocDisplayNames": null, + "id": 4, + "isCollapsed": false, + "key": null, + "ownerID": 2, + "parentID": 3, + "type": 5, + "weight": 3, + }, + Object { + "children": Array [ + 6, + ], + "depth": 1, + "displayName": "Suspense", + "hocDisplayNames": null, + "id": 5, + "isCollapsed": false, + "key": null, + "ownerID": 4, + "parentID": 4, + "type": 12, + "weight": 2, + }, + Object { + "children": Array [], + "depth": 2, + "displayName": "Grandchild", + "hocDisplayNames": null, + "id": 6, + "isCollapsed": false, + "key": null, + "ownerID": 4, + "parentID": 5, + "type": 5, + "weight": 1, + }, + ], + "ownerID": 4, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 4, + "selectedElementIndex": 0, +} +`; + +exports[`TreeListContext owners state should exit the owners list if an element outside the list is selected: 3: child owners tree 1`] = ` +Object { + "inspectedElementID": 5, + "numElements": 3, + "ownerFlatTree": Array [ + Object { + "children": Array [ + 5, + ], + "depth": 0, + "displayName": "Child", + "hocDisplayNames": null, + "id": 4, + "isCollapsed": false, + "key": null, + "ownerID": 2, + "parentID": 3, + "type": 5, + "weight": 3, + }, + Object { + "children": Array [ + 6, + ], + "depth": 1, + "displayName": "Suspense", + "hocDisplayNames": null, + "id": 5, + "isCollapsed": false, + "key": null, + "ownerID": 4, + "parentID": 4, + "type": 12, + "weight": 2, + }, + Object { + "children": Array [], + "depth": 2, + "displayName": "Grandchild", + "hocDisplayNames": null, + "id": 6, + "isCollapsed": false, + "key": null, + "ownerID": 4, + "parentID": 5, + "type": 5, + "weight": 1, + }, + ], + "ownerID": 4, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 5, + "selectedElementIndex": 1, +} +`; + +exports[`TreeListContext owners state should exit the owners list if an element outside the list is selected: 4: main tree 1`] = ` +Object { + "inspectedElementID": 5, + "numElements": 5, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 5, + "selectedElementIndex": 1, +} +`; + +exports[`TreeListContext owners state should exit the owners list if the current owner is unmounted: 0: mount 1`] = ` +[root] + ▾ + +`; + +exports[`TreeListContext owners state should exit the owners list if the current owner is unmounted: 1: initial state 1`] = ` +Object { + "inspectedElementID": null, + "numElements": 2, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": null, + "selectedElementIndex": null, +} +`; + +exports[`TreeListContext owners state should exit the owners list if the current owner is unmounted: 2: child owners tree 1`] = ` +Object { + "inspectedElementID": 3, + "numElements": 1, + "ownerFlatTree": Array [ + Object { + "children": Array [], + "depth": 0, + "displayName": "Child", + "hocDisplayNames": null, + "id": 3, + "isCollapsed": false, + "key": null, + "ownerID": 0, + "parentID": 2, + "type": 5, + "weight": 1, + }, + ], + "ownerID": 3, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 3, + "selectedElementIndex": 0, +} +`; + +exports[`TreeListContext owners state should exit the owners list if the current owner is unmounted: 3: remove child 1`] = ` +Object { + "inspectedElementID": null, + "numElements": 1, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": null, + "selectedElementIndex": 0, +} +`; + +exports[`TreeListContext owners state should exit the owners list if the current owner is unmounted: 4: parent owners tree 1`] = ` +Object { + "inspectedElementID": 2, + "numElements": 1, + "ownerFlatTree": Array [ + Object { + "children": Array [], + "depth": 0, + "displayName": "Parent", + "hocDisplayNames": null, + "id": 2, + "isCollapsed": false, + "key": null, + "ownerID": 0, + "parentID": 1, + "type": 5, + "weight": 1, + }, + ], + "ownerID": 2, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 2, + "selectedElementIndex": 0, +} +`; + +exports[`TreeListContext owners state should exit the owners list if the current owner is unmounted: 5: unmount root 1`] = ` +Object { + "inspectedElementID": null, + "numElements": 0, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": null, + "selectedElementIndex": 0, +} +`; + +exports[`TreeListContext owners state should remove an element from the owners list if it is unmounted: 0: mount 1`] = ` +[root] + ▾ + ▾ + + +`; + +exports[`TreeListContext owners state should remove an element from the owners list if it is unmounted: 1: initial state 1`] = ` +Object { + "inspectedElementID": null, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": null, + "selectedElementIndex": null, +} +`; + +exports[`TreeListContext owners state should remove an element from the owners list if it is unmounted: 2: parent owners tree 1`] = ` +Object { + "inspectedElementID": 3, + "numElements": 3, + "ownerFlatTree": Array [ + Object { + "children": Array [ + 4, + 5, + ], + "depth": 0, + "displayName": "Parent", + "hocDisplayNames": null, + "id": 3, + "isCollapsed": false, + "key": null, + "ownerID": 2, + "parentID": 2, + "type": 5, + "weight": 3, + }, + Object { + "children": Array [], + "depth": 1, + "displayName": "Child", + "hocDisplayNames": null, + "id": 4, + "isCollapsed": false, + "key": "0", + "ownerID": 3, + "parentID": 3, + "type": 5, + "weight": 1, + }, + Object { + "children": Array [], + "depth": 1, + "displayName": "Child", + "hocDisplayNames": null, + "id": 5, + "isCollapsed": false, + "key": "1", + "ownerID": 3, + "parentID": 3, + "type": 5, + "weight": 1, + }, + ], + "ownerID": 3, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 3, + "selectedElementIndex": 0, +} +`; + +exports[`TreeListContext owners state should remove an element from the owners list if it is unmounted: 3: remove second child 1`] = ` +Object { + "inspectedElementID": 3, + "numElements": 2, + "ownerFlatTree": Array [ + Object { + "children": Array [ + 4, + ], + "depth": 0, + "displayName": "Parent", + "hocDisplayNames": null, + "id": 3, + "isCollapsed": false, + "key": null, + "ownerID": 2, + "parentID": 2, + "type": 5, + "weight": 2, + }, + Object { + "children": Array [], + "depth": 1, + "displayName": "Child", + "hocDisplayNames": null, + "id": 4, + "isCollapsed": false, + "key": "0", + "ownerID": 3, + "parentID": 3, + "type": 5, + "weight": 1, + }, + ], + "ownerID": 3, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 3, + "selectedElementIndex": 0, +} +`; + +exports[`TreeListContext owners state should remove an element from the owners list if it is unmounted: 4: remove first child 1`] = ` +Object { + "inspectedElementID": 3, + "numElements": 1, + "ownerFlatTree": Array [ + Object { + "children": Array [], + "depth": 0, + "displayName": "Parent", + "hocDisplayNames": null, + "id": 3, + "isCollapsed": false, + "key": null, + "ownerID": 2, + "parentID": 2, + "type": 5, + "weight": 1, + }, + ], + "ownerID": 3, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 3, + "selectedElementIndex": 0, +} +`; + +exports[`TreeListContext owners state should support entering and existing the owners tree view: 0: mount 1`] = ` +[root] + ▾ + ▾ + + +`; + +exports[`TreeListContext owners state should support entering and existing the owners tree view: 1: initial state 1`] = ` +Object { + "inspectedElementID": null, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": null, + "selectedElementIndex": null, +} +`; + +exports[`TreeListContext owners state should support entering and existing the owners tree view: 2: parent owners tree 1`] = ` +Object { + "inspectedElementID": 3, + "numElements": 3, + "ownerFlatTree": Array [ + Object { + "children": Array [ + 4, + 5, + ], + "depth": 0, + "displayName": "Parent", + "hocDisplayNames": null, + "id": 3, + "isCollapsed": false, + "key": null, + "ownerID": 2, + "parentID": 2, + "type": 5, + "weight": 3, + }, + Object { + "children": Array [], + "depth": 1, + "displayName": "Child", + "hocDisplayNames": null, + "id": 4, + "isCollapsed": false, + "key": null, + "ownerID": 3, + "parentID": 3, + "type": 5, + "weight": 1, + }, + Object { + "children": Array [], + "depth": 1, + "displayName": "Child", + "hocDisplayNames": null, + "id": 5, + "isCollapsed": false, + "key": null, + "ownerID": 3, + "parentID": 3, + "type": 5, + "weight": 1, + }, + ], + "ownerID": 3, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 3, + "selectedElementIndex": 0, +} +`; + +exports[`TreeListContext owners state should support entering and existing the owners tree view: 3: final state 1`] = ` +Object { + "inspectedElementID": 3, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 3, + "selectedElementIndex": 1, +} +`; + +exports[`TreeListContext search state should add newly mounted elements to the search results set if they match the current text: 0: mount 1`] = ` +[root] + + +`; + +exports[`TreeListContext search state should add newly mounted elements to the search results set if they match the current text: 1: initial state 1`] = ` +Object { + "inspectedElementID": null, + "numElements": 2, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": null, + "selectedElementIndex": null, +} +`; + +exports[`TreeListContext search state should add newly mounted elements to the search results set if they match the current text: 2: search for "ba" 1`] = ` +Object { + "inspectedElementID": 3, + "numElements": 2, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": 0, + "searchResults": Array [ + 3, + ], + "searchText": "ba", + "selectedElementID": 3, + "selectedElementIndex": 1, +} +`; + +exports[`TreeListContext search state should add newly mounted elements to the search results set if they match the current text: 3: mount Baz 1`] = ` +Object { + "inspectedElementID": 3, + "numElements": 3, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": 0, + "searchResults": Array [ + 3, + 4, + ], + "searchText": "ba", + "selectedElementID": 3, + "selectedElementIndex": 1, +} +`; + +exports[`TreeListContext search state should find elements matching search text: 0: mount 1`] = ` +[root] + + + + [withHOC] +`; + +exports[`TreeListContext search state should find elements matching search text: 1: initial state 1`] = ` +Object { + "inspectedElementID": null, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": null, + "selectedElementIndex": null, +} +`; + +exports[`TreeListContext search state should find elements matching search text: 2: search for "ba" 1`] = ` +Object { + "inspectedElementID": 3, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": 0, + "searchResults": Array [ + 3, + 4, + ], + "searchText": "ba", + "selectedElementID": 3, + "selectedElementIndex": 1, +} +`; + +exports[`TreeListContext search state should find elements matching search text: 3: search for "f" 1`] = ` +Object { + "inspectedElementID": 2, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": 0, + "searchResults": Array [ + 2, + ], + "searchText": "f", + "selectedElementID": 2, + "selectedElementIndex": 0, +} +`; + +exports[`TreeListContext search state should find elements matching search text: 4: search for "y" 1`] = ` +Object { + "inspectedElementID": 2, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "y", + "selectedElementID": 2, + "selectedElementIndex": 0, +} +`; + +exports[`TreeListContext search state should find elements matching search text: 5: search for "w" 1`] = ` +Object { + "inspectedElementID": 5, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": 0, + "searchResults": Array [ + 5, + ], + "searchText": "w", + "selectedElementID": 5, + "selectedElementIndex": 3, +} +`; + +exports[`TreeListContext search state should remove unmounted elements from the search results set: 0: mount 1`] = ` +[root] + + + +`; + +exports[`TreeListContext search state should remove unmounted elements from the search results set: 1: initial state 1`] = ` +Object { + "inspectedElementID": null, + "numElements": 3, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": null, + "selectedElementIndex": null, +} +`; + +exports[`TreeListContext search state should remove unmounted elements from the search results set: 2: search for "ba" 1`] = ` +Object { + "inspectedElementID": 3, + "numElements": 3, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": 0, + "searchResults": Array [ + 3, + 4, + ], + "searchText": "ba", + "selectedElementID": 3, + "selectedElementIndex": 1, +} +`; + +exports[`TreeListContext search state should remove unmounted elements from the search results set: 3: go to second result 1`] = ` +Object { + "inspectedElementID": 4, + "numElements": 3, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": 1, + "searchResults": Array [ + 3, + 4, + ], + "searchText": "ba", + "selectedElementID": 4, + "selectedElementIndex": 2, +} +`; + +exports[`TreeListContext search state should remove unmounted elements from the search results set: 4: unmount Baz 1`] = ` +Object { + "inspectedElementID": null, + "numElements": 2, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": 0, + "searchResults": Array [ + 3, + ], + "searchText": "ba", + "selectedElementID": null, + "selectedElementIndex": null, +} +`; + +exports[`TreeListContext search state should select the next and previous items within the search results: 0: mount 1`] = ` +[root] + + + + +`; + +exports[`TreeListContext search state should select the next and previous items within the search results: 1: initial state 1`] = ` +Object { + "inspectedElementID": null, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": null, + "selectedElementIndex": null, +} +`; + +exports[`TreeListContext search state should select the next and previous items within the search results: 2: search for "ba" 1`] = ` +Object { + "inspectedElementID": 3, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": 0, + "searchResults": Array [ + 3, + 4, + 5, + ], + "searchText": "ba", + "selectedElementID": 3, + "selectedElementIndex": 1, +} +`; + +exports[`TreeListContext search state should select the next and previous items within the search results: 3: go to second result 1`] = ` +Object { + "inspectedElementID": 4, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": 1, + "searchResults": Array [ + 3, + 4, + 5, + ], + "searchText": "ba", + "selectedElementID": 4, + "selectedElementIndex": 2, +} +`; + +exports[`TreeListContext search state should select the next and previous items within the search results: 4: go to third result 1`] = ` +Object { + "inspectedElementID": 5, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": 2, + "searchResults": Array [ + 3, + 4, + 5, + ], + "searchText": "ba", + "selectedElementID": 5, + "selectedElementIndex": 3, +} +`; + +exports[`TreeListContext search state should select the next and previous items within the search results: 5: go to second result 1`] = ` +Object { + "inspectedElementID": 4, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": 1, + "searchResults": Array [ + 3, + 4, + 5, + ], + "searchText": "ba", + "selectedElementID": 4, + "selectedElementIndex": 2, +} +`; + +exports[`TreeListContext search state should select the next and previous items within the search results: 6: go to first result 1`] = ` +Object { + "inspectedElementID": 3, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": 0, + "searchResults": Array [ + 3, + 4, + 5, + ], + "searchText": "ba", + "selectedElementID": 3, + "selectedElementIndex": 1, +} +`; + +exports[`TreeListContext search state should select the next and previous items within the search results: 7: wrap to last result 1`] = ` +Object { + "inspectedElementID": 5, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": 2, + "searchResults": Array [ + 3, + 4, + 5, + ], + "searchText": "ba", + "selectedElementID": 5, + "selectedElementIndex": 3, +} +`; + +exports[`TreeListContext search state should select the next and previous items within the search results: 8: wrap to first result 1`] = ` +Object { + "inspectedElementID": 3, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": 0, + "searchResults": Array [ + 3, + 4, + 5, + ], + "searchText": "ba", + "selectedElementID": 3, + "selectedElementIndex": 1, +} +`; + +exports[`TreeListContext tree state should clear selection if the selected element is unmounted: 0: mount 1`] = ` +[root] + ▾ + ▾ + + +`; + +exports[`TreeListContext tree state should clear selection if the selected element is unmounted: 1: initial state 1`] = ` +Object { + "inspectedElementID": null, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": null, + "selectedElementIndex": null, +} +`; + +exports[`TreeListContext tree state should clear selection if the selected element is unmounted: 2: select second child 1`] = ` +Object { + "inspectedElementID": 5, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 5, + "selectedElementIndex": 3, +} +`; + +exports[`TreeListContext tree state should clear selection if the selected element is unmounted: 3: remove children (parent should now be selected) 1`] = ` +Object { + "inspectedElementID": 3, + "numElements": 2, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 3, + "selectedElementIndex": 1, +} +`; + +exports[`TreeListContext tree state should clear selection if the selected element is unmounted: 4: unmount root (nothing should be selected) 1`] = ` +Object { + "inspectedElementID": null, + "numElements": 0, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": null, + "selectedElementIndex": null, +} +`; + +exports[`TreeListContext tree state should select child elements: 0: mount 1`] = ` +[root] + ▾ + ▾ + + + ▾ + + +`; + +exports[`TreeListContext tree state should select child elements: 1: initial state 1`] = ` +Object { + "inspectedElementID": null, + "numElements": 7, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": null, + "selectedElementIndex": null, +} +`; + +exports[`TreeListContext tree state should select child elements: 2: select first element 1`] = ` +Object { + "inspectedElementID": 2, + "numElements": 7, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 2, + "selectedElementIndex": 0, +} +`; + +exports[`TreeListContext tree state should select child elements: 3: select Parent 1`] = ` +Object { + "inspectedElementID": 3, + "numElements": 7, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 3, + "selectedElementIndex": 1, +} +`; + +exports[`TreeListContext tree state should select child elements: 4: select Child 1`] = ` +Object { + "inspectedElementID": 4, + "numElements": 7, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 4, + "selectedElementIndex": 2, +} +`; + +exports[`TreeListContext tree state should select parent elements and then collapse: 0: mount 1`] = ` +[root] + ▾ + ▾ + + + ▾ + + +`; + +exports[`TreeListContext tree state should select parent elements and then collapse: 1: initial state 1`] = ` +Object { + "inspectedElementID": null, + "numElements": 7, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": null, + "selectedElementIndex": null, +} +`; + +exports[`TreeListContext tree state should select parent elements and then collapse: 2: select last child 1`] = ` +Object { + "inspectedElementID": 8, + "numElements": 7, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 8, + "selectedElementIndex": 6, +} +`; + +exports[`TreeListContext tree state should select parent elements and then collapse: 3: select Parent 1`] = ` +Object { + "inspectedElementID": 6, + "numElements": 7, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 6, + "selectedElementIndex": 4, +} +`; + +exports[`TreeListContext tree state should select parent elements and then collapse: 4: select Grandparent 1`] = ` +Object { + "inspectedElementID": 2, + "numElements": 7, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 2, + "selectedElementIndex": 0, +} +`; + +exports[`TreeListContext tree state should select the next and previous elements in the tree: 0: mount 1`] = ` +[root] + ▾ + ▾ + + +`; + +exports[`TreeListContext tree state should select the next and previous elements in the tree: 1: initial state 1`] = ` +Object { + "inspectedElementID": null, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": null, + "selectedElementIndex": null, +} +`; + +exports[`TreeListContext tree state should select the next and previous elements in the tree: 2: select first element 1`] = ` +Object { + "inspectedElementID": 2, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 2, + "selectedElementIndex": 0, +} +`; + +exports[`TreeListContext tree state should select the next and previous elements in the tree: 3: select element after (0) 1`] = ` +Object { + "inspectedElementID": 3, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 3, + "selectedElementIndex": 1, +} +`; + +exports[`TreeListContext tree state should select the next and previous elements in the tree: 3: select element after (1) 1`] = ` +Object { + "inspectedElementID": 4, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 4, + "selectedElementIndex": 2, +} +`; + +exports[`TreeListContext tree state should select the next and previous elements in the tree: 3: select element after (2) 1`] = ` +Object { + "inspectedElementID": 5, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 5, + "selectedElementIndex": 3, +} +`; + +exports[`TreeListContext tree state should select the next and previous elements in the tree: 4: select element before (1) 1`] = ` +Object { + "inspectedElementID": 2, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 2, + "selectedElementIndex": 0, +} +`; + +exports[`TreeListContext tree state should select the next and previous elements in the tree: 4: select element before (2) 1`] = ` +Object { + "inspectedElementID": 3, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 3, + "selectedElementIndex": 1, +} +`; + +exports[`TreeListContext tree state should select the next and previous elements in the tree: 4: select element before (3) 1`] = ` +Object { + "inspectedElementID": 4, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 4, + "selectedElementIndex": 2, +} +`; + +exports[`TreeListContext tree state should select the next and previous elements in the tree: 5: select previous wraps around to last 1`] = ` +Object { + "inspectedElementID": 5, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 5, + "selectedElementIndex": 3, +} +`; + +exports[`TreeListContext tree state should select the next and previous elements in the tree: 6: select next wraps around to first 1`] = ` +Object { + "inspectedElementID": 2, + "numElements": 4, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "", + "selectedElementID": 2, + "selectedElementIndex": 0, +} +`; diff --git a/extension/src/__tests__/bridge-test.js b/extension/src/__tests__/bridge-test.js new file mode 100644 index 0000000000000..fe89a6c2b10f9 --- /dev/null +++ b/extension/src/__tests__/bridge-test.js @@ -0,0 +1,42 @@ +// @flow + +describe('Bridge', () => { + let Bridge; + + beforeEach(() => { + Bridge = require('src/bridge').default; + }); + + it('should shutdown properly', () => { + const wall = { + listen: jest.fn(() => () => {}), + send: jest.fn(), + }; + const bridge = new Bridge(wall); + + // Check that we're wired up correctly. + bridge.send('reloadAppForProfiling'); + jest.runAllTimers(); + expect(wall.send).toHaveBeenCalledWith('reloadAppForProfiling'); + + // Should flush pending messages and then shut down. + wall.send.mockClear(); + bridge.send('update', '1'); + bridge.send('update', '2'); + bridge.shutdown(); + jest.runAllTimers(); + expect(wall.send).toHaveBeenCalledWith('update', '1'); + expect(wall.send).toHaveBeenCalledWith('update', '2'); + expect(wall.send).toHaveBeenCalledWith('shutdown'); + + // Verify that the Bridge doesn't send messages after shutdown. + spyOn(console, 'warn'); + wall.send.mockClear(); + bridge.send('should not send'); + jest.runAllTimers(); + expect(wall.send).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith( + 'Cannot send message "should not send" through a Bridge that has been shutdown.' + ); + }); +}); diff --git a/extension/src/__tests__/console-test.js b/extension/src/__tests__/console-test.js new file mode 100644 index 0000000000000..a633e6f817a11 --- /dev/null +++ b/extension/src/__tests__/console-test.js @@ -0,0 +1,333 @@ +// @flow + +describe('console', () => { + let React; + let ReactDOM; + let act; + let fakeConsole; + let mockError; + let mockInfo; + let mockLog; + let mockWarn; + let patchConsole; + let unpatchConsole; + + beforeEach(() => { + const Console = require('../backend/console'); + patchConsole = Console.patch; + unpatchConsole = Console.unpatch; + + const inject = global.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject; + global.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject = internals => { + inject(internals); + + Console.registerRenderer(internals); + }; + + React = require('react'); + ReactDOM = require('react-dom'); + + const utils = require('./utils'); + act = utils.act; + + // Patch a fake console so we can verify with tests below. + // Patching the real console is too complicated, + // because Jest itself has hooks into it as does our test env setup. + mockError = jest.fn(); + mockInfo = jest.fn(); + mockLog = jest.fn(); + mockWarn = jest.fn(); + fakeConsole = { + error: mockError, + info: mockInfo, + log: mockLog, + warn: mockWarn, + }; + + Console.dangerous_setTargetConsoleForTesting(fakeConsole); + + patchConsole(); + }); + + function normalizeCodeLocInfo(str) { + return str && str.replace(/\(at .+?:\d+\)/g, '(at **)'); + } + + it('should not patch console methods that do not receive component stacks', () => { + expect(fakeConsole.error).not.toBe(mockError); + expect(fakeConsole.info).toBe(mockInfo); + expect(fakeConsole.log).toBe(mockLog); + expect(fakeConsole.warn).not.toBe(mockWarn); + }); + + it('should only patch the console once', () => { + const { error, warn } = fakeConsole; + + patchConsole(); + + expect(fakeConsole.error).toBe(error); + expect(fakeConsole.warn).toBe(warn); + }); + + it('should un-patch when requested', () => { + expect(fakeConsole.error).not.toBe(mockError); + expect(fakeConsole.warn).not.toBe(mockWarn); + + unpatchConsole(); + + expect(fakeConsole.error).toBe(mockError); + expect(fakeConsole.warn).toBe(mockWarn); + }); + + it('should pass through logs when there is no current fiber', () => { + expect(mockLog).toHaveBeenCalledTimes(0); + expect(mockWarn).toHaveBeenCalledTimes(0); + expect(mockError).toHaveBeenCalledTimes(0); + fakeConsole.log('log'); + fakeConsole.warn('warn'); + fakeConsole.error('error'); + expect(mockLog).toHaveBeenCalledTimes(1); + expect(mockLog.mock.calls[0]).toHaveLength(1); + expect(mockLog.mock.calls[0][0]).toBe('log'); + expect(mockWarn).toHaveBeenCalledTimes(1); + expect(mockWarn.mock.calls[0]).toHaveLength(1); + expect(mockWarn.mock.calls[0][0]).toBe('warn'); + expect(mockError).toHaveBeenCalledTimes(1); + expect(mockError.mock.calls[0]).toHaveLength(1); + expect(mockError.mock.calls[0][0]).toBe('error'); + }); + + it('should not append multiple stacks', () => { + const Child = () => { + fakeConsole.warn('warn\n in Child (at fake.js:123)'); + fakeConsole.error('error', '\n in Child (at fake.js:123)'); + return null; + }; + + act(() => ReactDOM.render(, document.createElement('div'))); + + expect(mockWarn).toHaveBeenCalledTimes(1); + expect(mockWarn.mock.calls[0]).toHaveLength(1); + expect(mockWarn.mock.calls[0][0]).toBe( + 'warn\n in Child (at fake.js:123)' + ); + expect(mockError).toHaveBeenCalledTimes(1); + expect(mockError.mock.calls[0]).toHaveLength(2); + expect(mockError.mock.calls[0][0]).toBe('error'); + expect(mockError.mock.calls[0][1]).toBe('\n in Child (at fake.js:123)'); + }); + + it('should append component stacks to errors and warnings logged during render', () => { + const Intermediate = ({ children }) => children; + const Parent = () => ( + + + + ); + const Child = () => { + fakeConsole.error('error'); + fakeConsole.log('log'); + fakeConsole.warn('warn'); + return null; + }; + + act(() => ReactDOM.render(, document.createElement('div'))); + + expect(mockLog).toHaveBeenCalledTimes(1); + expect(mockLog.mock.calls[0]).toHaveLength(1); + expect(mockLog.mock.calls[0][0]).toBe('log'); + expect(mockWarn).toHaveBeenCalledTimes(1); + expect(mockWarn.mock.calls[0]).toHaveLength(2); + expect(mockWarn.mock.calls[0][0]).toBe('warn'); + expect(normalizeCodeLocInfo(mockWarn.mock.calls[0][1])).toEqual( + '\n in Child (at **)\n in Parent (at **)' + ); + expect(mockError).toHaveBeenCalledTimes(1); + expect(mockError.mock.calls[0]).toHaveLength(2); + expect(mockError.mock.calls[0][0]).toBe('error'); + expect(normalizeCodeLocInfo(mockError.mock.calls[0][1])).toBe( + '\n in Child (at **)\n in Parent (at **)' + ); + }); + + it('should append component stacks to errors and warnings logged from effects', () => { + const Intermediate = ({ children }) => children; + const Parent = () => ( + + + + ); + const Child = () => { + React.useLayoutEffect(() => { + fakeConsole.error('active error'); + fakeConsole.log('active log'); + fakeConsole.warn('active warn'); + }); + React.useEffect(() => { + fakeConsole.error('passive error'); + fakeConsole.log('passive log'); + fakeConsole.warn('passive warn'); + }); + return null; + }; + + act(() => ReactDOM.render(, document.createElement('div'))); + + expect(mockLog).toHaveBeenCalledTimes(2); + expect(mockLog.mock.calls[0]).toHaveLength(1); + expect(mockLog.mock.calls[0][0]).toBe('active log'); + expect(mockLog.mock.calls[1]).toHaveLength(1); + expect(mockLog.mock.calls[1][0]).toBe('passive log'); + expect(mockWarn).toHaveBeenCalledTimes(2); + expect(mockWarn.mock.calls[0]).toHaveLength(2); + expect(mockWarn.mock.calls[0][0]).toBe('active warn'); + expect(normalizeCodeLocInfo(mockWarn.mock.calls[0][1])).toEqual( + '\n in Child (at **)\n in Parent (at **)' + ); + expect(mockWarn.mock.calls[1]).toHaveLength(2); + expect(mockWarn.mock.calls[1][0]).toBe('passive warn'); + expect(normalizeCodeLocInfo(mockWarn.mock.calls[1][1])).toEqual( + '\n in Child (at **)\n in Parent (at **)' + ); + expect(mockError).toHaveBeenCalledTimes(2); + expect(mockError.mock.calls[0]).toHaveLength(2); + expect(mockError.mock.calls[0][0]).toBe('active error'); + expect(normalizeCodeLocInfo(mockError.mock.calls[0][1])).toBe( + '\n in Child (at **)\n in Parent (at **)' + ); + expect(mockError.mock.calls[1]).toHaveLength(2); + expect(mockError.mock.calls[1][0]).toBe('passive error'); + expect(normalizeCodeLocInfo(mockError.mock.calls[1][1])).toBe( + '\n in Child (at **)\n in Parent (at **)' + ); + }); + + it('should append component stacks to errors and warnings logged from commit hooks', () => { + const Intermediate = ({ children }) => children; + const Parent = () => ( + + + + ); + class Child extends React.Component { + componentDidMount() { + fakeConsole.error('didMount error'); + fakeConsole.log('didMount log'); + fakeConsole.warn('didMount warn'); + } + componentDidUpdate() { + fakeConsole.error('didUpdate error'); + fakeConsole.log('didUpdate log'); + fakeConsole.warn('didUpdate warn'); + } + render() { + return null; + } + } + + const container = document.createElement('div'); + act(() => ReactDOM.render(, container)); + act(() => ReactDOM.render(, container)); + + expect(mockLog).toHaveBeenCalledTimes(2); + expect(mockLog.mock.calls[0]).toHaveLength(1); + expect(mockLog.mock.calls[0][0]).toBe('didMount log'); + expect(mockLog.mock.calls[1]).toHaveLength(1); + expect(mockLog.mock.calls[1][0]).toBe('didUpdate log'); + expect(mockWarn).toHaveBeenCalledTimes(2); + expect(mockWarn.mock.calls[0]).toHaveLength(2); + expect(mockWarn.mock.calls[0][0]).toBe('didMount warn'); + expect(normalizeCodeLocInfo(mockWarn.mock.calls[0][1])).toEqual( + '\n in Child (at **)\n in Parent (at **)' + ); + expect(mockWarn.mock.calls[1]).toHaveLength(2); + expect(mockWarn.mock.calls[1][0]).toBe('didUpdate warn'); + expect(normalizeCodeLocInfo(mockWarn.mock.calls[1][1])).toEqual( + '\n in Child (at **)\n in Parent (at **)' + ); + expect(mockError).toHaveBeenCalledTimes(2); + expect(mockError.mock.calls[0]).toHaveLength(2); + expect(mockError.mock.calls[0][0]).toBe('didMount error'); + expect(normalizeCodeLocInfo(mockError.mock.calls[0][1])).toBe( + '\n in Child (at **)\n in Parent (at **)' + ); + expect(mockError.mock.calls[1]).toHaveLength(2); + expect(mockError.mock.calls[1][0]).toBe('didUpdate error'); + expect(normalizeCodeLocInfo(mockError.mock.calls[1][1])).toBe( + '\n in Child (at **)\n in Parent (at **)' + ); + }); + + it('should append component stacks to errors and warnings logged from gDSFP', () => { + const Intermediate = ({ children }) => children; + const Parent = () => ( + + + + ); + class Child extends React.Component { + state = {}; + static getDerivedStateFromProps() { + fakeConsole.error('error'); + fakeConsole.log('log'); + fakeConsole.warn('warn'); + return null; + } + render() { + return null; + } + } + + act(() => ReactDOM.render(, document.createElement('div'))); + + expect(mockLog).toHaveBeenCalledTimes(1); + expect(mockLog.mock.calls[0]).toHaveLength(1); + expect(mockLog.mock.calls[0][0]).toBe('log'); + expect(mockWarn).toHaveBeenCalledTimes(1); + expect(mockWarn.mock.calls[0]).toHaveLength(2); + expect(mockWarn.mock.calls[0][0]).toBe('warn'); + expect(normalizeCodeLocInfo(mockWarn.mock.calls[0][1])).toEqual( + '\n in Child (at **)\n in Parent (at **)' + ); + expect(mockError).toHaveBeenCalledTimes(1); + expect(mockError.mock.calls[0]).toHaveLength(2); + expect(mockError.mock.calls[0][0]).toBe('error'); + expect(normalizeCodeLocInfo(mockError.mock.calls[0][1])).toBe( + '\n in Child (at **)\n in Parent (at **)' + ); + }); + + it('should append stacks after being uninstalled and reinstalled', () => { + const Child = () => { + fakeConsole.warn('warn'); + fakeConsole.error('error'); + return null; + }; + + unpatchConsole(); + act(() => ReactDOM.render(, document.createElement('div'))); + + expect(mockWarn).toHaveBeenCalledTimes(1); + expect(mockWarn.mock.calls[0]).toHaveLength(1); + expect(mockWarn.mock.calls[0][0]).toBe('warn'); + expect(mockError).toHaveBeenCalledTimes(1); + expect(mockError.mock.calls[0]).toHaveLength(1); + expect(mockError.mock.calls[0][0]).toBe('error'); + + patchConsole(); + act(() => ReactDOM.render(, document.createElement('div'))); + + expect(mockWarn).toHaveBeenCalledTimes(2); + expect(mockWarn.mock.calls[1]).toHaveLength(2); + expect(mockWarn.mock.calls[1][0]).toBe('warn'); + expect(normalizeCodeLocInfo(mockWarn.mock.calls[1][1])).toEqual( + '\n in Child (at **)' + ); + expect(mockError).toHaveBeenCalledTimes(2); + expect(mockError.mock.calls[1]).toHaveLength(2); + expect(mockError.mock.calls[1][0]).toBe('error'); + expect(normalizeCodeLocInfo(mockError.mock.calls[1][1])).toBe( + '\n in Child (at **)' + ); + }); +}); diff --git a/extension/src/__tests__/inspectedElementContext-test.js b/extension/src/__tests__/inspectedElementContext-test.js new file mode 100644 index 0000000000000..2cf1ed8632975 --- /dev/null +++ b/extension/src/__tests__/inspectedElementContext-test.js @@ -0,0 +1,859 @@ +// @flow + +import typeof ReactTestRenderer from 'react-test-renderer'; +import type { GetInspectedElementPath } from 'src/devtools/views/Components/InspectedElementContext'; +import type { FrontendBridge } from 'src/bridge'; +import type Store from 'src/devtools/store'; + +describe('InspectedElementContext', () => { + let React; + let ReactDOM; + let TestRenderer: ReactTestRenderer; + let bridge: FrontendBridge; + let store: Store; + let meta; + let utils; + + let BridgeContext; + let InspectedElementContext; + let InspectedElementContextController; + let StoreContext; + let TestUtils; + let TreeContextController; + + beforeEach(() => { + utils = require('./utils'); + utils.beforeEachProfiling(); + + meta = require('src/hydration').meta; + + bridge = global.bridge; + store = global.store; + store.collapseNodesByDefault = false; + + React = require('react'); + ReactDOM = require('react-dom'); + TestUtils = require('react-dom/test-utils'); + TestRenderer = utils.requireTestRenderer(); + + BridgeContext = require('src/devtools/views/context').BridgeContext; + InspectedElementContext = require('src/devtools/views/Components/InspectedElementContext') + .InspectedElementContext; + InspectedElementContextController = require('src/devtools/views/Components/InspectedElementContext') + .InspectedElementContextController; + StoreContext = require('src/devtools/views/context').StoreContext; + TreeContextController = require('src/devtools/views/Components/TreeContext') + .TreeContextController; + }); + + const Contexts = ({ + children, + defaultSelectedElementID = null, + defaultSelectedElementIndex = null, + }) => ( + + + + + {children} + + + + + ); + + it('should inspect the currently selected element', async done => { + const Example = () => { + const [count] = React.useState(1); + return count; + }; + + const container = document.createElement('div'); + await utils.actAsync(() => + ReactDOM.render(, container) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + + let didFinish = false; + + function Suspender({ target }) { + const { getInspectedElement } = React.useContext(InspectedElementContext); + const inspectedElement = getInspectedElement(id); + expect(inspectedElement).toMatchSnapshot(`1: Inspected element ${id}`); + didFinish = true; + return null; + } + + await utils.actAsync( + () => + TestRenderer.create( + + + + + + ), + false + ); + expect(didFinish).toBe(true); + + done(); + }); + + it('should poll for updates for the currently selected element', async done => { + const Example = () => null; + + const container = document.createElement('div'); + await utils.actAsync( + () => ReactDOM.render(, container), + false + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + + let inspectedElement = null; + + function Suspender({ target }) { + const { getInspectedElement } = React.useContext(InspectedElementContext); + inspectedElement = getInspectedElement(id); + return null; + } + + let renderer; + + await utils.actAsync(() => { + renderer = TestRenderer.create( + + + + + + ); + }, false); + expect(inspectedElement).toMatchSnapshot('1: initial render'); + + await utils.actAsync( + () => ReactDOM.render(, container), + false + ); + + inspectedElement = null; + await utils.actAsync( + () => + renderer.update( + + + + + + ), + false + ); + expect(inspectedElement).toMatchSnapshot('2: updated state'); + + done(); + }); + + it('should not re-render a function with hooks if it did not update since it was last inspected', async done => { + let targetRenderCount = 0; + + const Wrapper = ({ children }) => children; + const Target = React.memo(props => { + targetRenderCount++; + React.useState(0); + return null; + }); + + const container = document.createElement('div'); + await utils.actAsync(() => + ReactDOM.render( + + + , + container + ) + ); + + const id = ((store.getElementIDAtIndex(1): any): number); + + let inspectedElement = null; + + function Suspender({ target }) { + const { getInspectedElement } = React.useContext(InspectedElementContext); + inspectedElement = getInspectedElement(target); + return null; + } + + targetRenderCount = 0; + + let renderer; + await utils.actAsync( + () => + (renderer = TestRenderer.create( + + + + + + )), + false + ); + expect(targetRenderCount).toBe(1); + expect(inspectedElement).toMatchSnapshot('1: initial render'); + + const initialInspectedElement = inspectedElement; + + targetRenderCount = 0; + inspectedElement = null; + await utils.actAsync( + () => + renderer.update( + + + + + + ), + false + ); + expect(targetRenderCount).toBe(0); + expect(inspectedElement).toEqual(initialInspectedElement); + + targetRenderCount = 0; + + await utils.actAsync( + () => + ReactDOM.render( + + + , + container + ), + false + ); + + // Target should have been rendered once (by ReactDOM) and once by DevTools for inspection. + expect(targetRenderCount).toBe(2); + expect(inspectedElement).toMatchSnapshot('2: updated state'); + + done(); + }); + + it('should temporarily disable console logging when re-running a component to inspect its hooks', async done => { + let targetRenderCount = 0; + + const errorSpy = ((console: any).error = jest.fn()); + const infoSpy = ((console: any).info = jest.fn()); + const logSpy = ((console: any).log = jest.fn()); + const warnSpy = ((console: any).warn = jest.fn()); + + const Target = React.memo(props => { + targetRenderCount++; + console.error('error'); + console.info('info'); + console.log('log'); + console.warn('warn'); + React.useState(0); + return null; + }); + + const container = document.createElement('div'); + await utils.actAsync(() => + ReactDOM.render(, container) + ); + + expect(targetRenderCount).toBe(1); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith('error'); + expect(infoSpy).toHaveBeenCalledTimes(1); + expect(infoSpy).toHaveBeenCalledWith('info'); + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith('log'); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith('warn'); + + const id = ((store.getElementIDAtIndex(0): any): number); + + let inspectedElement = null; + + function Suspender({ target }) { + const { getInspectedElement } = React.useContext(InspectedElementContext); + inspectedElement = getInspectedElement(target); + return null; + } + + await utils.actAsync( + () => + TestRenderer.create( + + + + + + ), + false + ); + + expect(inspectedElement).not.toBe(null); + expect(targetRenderCount).toBe(2); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(infoSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + + done(); + }); + + it('should support simple data types', async done => { + const Example = () => null; + + const container = document.createElement('div'); + await utils.actAsync(() => + ReactDOM.render( + , + container + ) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + let inspectedElement = null; + + function Suspender({ target }) { + const { getInspectedElement } = React.useContext(InspectedElementContext); + inspectedElement = getInspectedElement(id); + return null; + } + + await utils.actAsync( + () => + TestRenderer.create( + + + + + + ), + 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); + 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(); + + done(); + }); + + it('should support complex data types', async done => { + const Example = () => null; + + const div = document.createElement('div'); + const exmapleFunction = () => {}; + const typedArray = new Uint8Array(3); + + const container = document.createElement('div'); + await utils.actAsync(() => + ReactDOM.render( + } + array_buffer={typedArray.buffer} + typed_array={typedArray} + date={new Date()} + />, + container + ) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + + let inspectedElement = null; + + function Suspender({ target }) { + const { getInspectedElement } = React.useContext(InspectedElementContext); + inspectedElement = getInspectedElement(id); + return null; + } + + await utils.actAsync( + () => + TestRenderer.create( + + + + + + ), + false + ); + + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot(`1: Inspected element ${id}`); + + const { + html_element, + fn, + symbol, + react_element, + array_buffer, + typed_array, + date, + } = (inspectedElement: any).props; + expect(html_element[meta.inspectable]).toBe(false); + expect(html_element[meta.name]).toBe('DIV'); + expect(html_element[meta.type]).toBe('html_element'); + expect(fn[meta.inspectable]).toBe(false); + expect(fn[meta.name]).toBe('exmapleFunction'); + expect(fn[meta.type]).toBe('function'); + expect(symbol[meta.inspectable]).toBe(false); + expect(symbol[meta.name]).toBe('Symbol(symbol)'); + expect(symbol[meta.type]).toBe('symbol'); + expect(react_element[meta.inspectable]).toBe(false); + expect(react_element[meta.name]).toBe('span'); + expect(react_element[meta.type]).toBe('react_element'); + expect(array_buffer[meta.size]).toBe(3); + expect(array_buffer[meta.inspectable]).toBe(false); + expect(array_buffer[meta.name]).toBe('ArrayBuffer'); + expect(array_buffer[meta.type]).toBe('array_buffer'); + expect(typed_array[meta.size]).toBe(3); + expect(typed_array[meta.inspectable]).toBe(false); + expect(typed_array[meta.name]).toBe('Uint8Array'); + expect(typed_array[meta.type]).toBe('typed_array'); + expect(date[meta.inspectable]).toBe(false); + expect(date[meta.type]).toBe('date'); + + done(); + }); + + it('should support custom objects with enumerable properties and getters', async done => { + class CustomData { + _number = 42; + get number() { + return this._number; + } + set number(value) { + this._number = value; + } + } + + const descriptor = ((Object.getOwnPropertyDescriptor( + CustomData.prototype, + 'number' + ): any): PropertyDescriptor); + descriptor.enumerable = true; + Object.defineProperty(CustomData.prototype, 'number', descriptor); + + const Example = () => null; + + const container = document.createElement('div'); + await utils.actAsync(() => + ReactDOM.render(, container) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + + let didFinish = false; + + function Suspender({ target }) { + const { getInspectedElement } = React.useContext(InspectedElementContext); + const inspectedElement = getInspectedElement(id); + expect(inspectedElement).toMatchSnapshot(`1: Inspected element ${id}`); + didFinish = true; + return null; + } + + await utils.actAsync( + () => + TestRenderer.create( + + + + + + ), + false + ); + expect(didFinish).toBe(true); + + done(); + }); + + it('should not dehydrate nested values until explicitly requested', async done => { + const Example = () => { + const [state] = React.useState({ + foo: { + bar: { + baz: 'hi', + }, + }, + }); + + return state.foo.bar.baz; + }; + + const container = document.createElement('div'); + await utils.actAsync(() => + ReactDOM.render( + , + container + ) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + + let getInspectedElementPath: GetInspectedElementPath = ((null: any): GetInspectedElementPath); + let inspectedElement = null; + + function Suspender({ target }) { + const context = React.useContext(InspectedElementContext); + getInspectedElementPath = context.getInspectedElementPath; + inspectedElement = context.getInspectedElement(target); + return null; + } + + await utils.actAsync( + () => + TestRenderer.create( + + + + + + ), + false + ); + expect(getInspectedElementPath).not.toBeNull(); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot('1: Initially inspect element'); + + inspectedElement = null; + TestUtils.act(() => { + TestRenderer.act(() => { + getInspectedElementPath(id, ['props', 'nestedObject', 'a']); + jest.runOnlyPendingTimers(); + }); + }); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot('2: Inspect props.nestedObject.a'); + + inspectedElement = null; + TestUtils.act(() => { + TestRenderer.act(() => { + getInspectedElementPath(id, ['props', 'nestedObject', 'a', 'b', 'c']); + jest.runOnlyPendingTimers(); + }); + }); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot( + '3: Inspect props.nestedObject.a.b.c' + ); + + inspectedElement = null; + TestUtils.act(() => { + TestRenderer.act(() => { + getInspectedElementPath(id, [ + 'props', + 'nestedObject', + 'a', + 'b', + 'c', + 0, + 'd', + ]); + jest.runOnlyPendingTimers(); + }); + }); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot( + '4: Inspect props.nestedObject.a.b.c.0.d' + ); + + inspectedElement = null; + TestUtils.act(() => { + TestRenderer.act(() => { + getInspectedElementPath(id, ['hooks', 0, 'value']); + jest.runOnlyPendingTimers(); + }); + }); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot('5: Inspect hooks.0.value'); + + inspectedElement = null; + TestUtils.act(() => { + TestRenderer.act(() => { + getInspectedElementPath(id, ['hooks', 0, 'value', 'foo', 'bar']); + jest.runOnlyPendingTimers(); + }); + }); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot( + '6: Inspect hooks.0.value.foo.bar' + ); + + done(); + }); + + it('should include updates for nested values that were previously hydrated', async done => { + const Example = () => null; + + const container = document.createElement('div'); + await utils.actAsync(() => + ReactDOM.render( + , + container + ) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + + let getInspectedElementPath: GetInspectedElementPath = ((null: any): GetInspectedElementPath); + let inspectedElement = null; + + function Suspender({ target }) { + const context = React.useContext(InspectedElementContext); + getInspectedElementPath = context.getInspectedElementPath; + inspectedElement = context.getInspectedElement(id); + return null; + } + + await utils.actAsync( + () => + TestRenderer.create( + + + + + + ), + false + ); + expect(getInspectedElementPath).not.toBeNull(); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot('1: Initially inspect element'); + + inspectedElement = null; + TestRenderer.act(() => { + getInspectedElementPath(id, ['props', 'nestedObject', 'a']); + jest.runOnlyPendingTimers(); + }); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot('2: Inspect props.nestedObject.a'); + + inspectedElement = null; + TestRenderer.act(() => { + getInspectedElementPath(id, ['props', 'nestedObject', 'c']); + jest.runOnlyPendingTimers(); + }); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot('3: Inspect props.nestedObject.c'); + + TestRenderer.act(() => { + TestUtils.act(() => { + ReactDOM.render( + , + container + ); + }); + }); + + TestRenderer.act(() => { + inspectedElement = null; + jest.advanceTimersByTime(1000); + }); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot('4: update inspected element'); + + done(); + }); + + it('should not tear if hydration is requested after an update', async done => { + const Example = () => null; + + const container = document.createElement('div'); + await utils.actAsync(() => + ReactDOM.render( + , + container + ) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + + let getInspectedElementPath: GetInspectedElementPath = ((null: any): GetInspectedElementPath); + let inspectedElement = null; + + function Suspender({ target }) { + const context = React.useContext(InspectedElementContext); + getInspectedElementPath = context.getInspectedElementPath; + inspectedElement = context.getInspectedElement(id); + return null; + } + + await utils.actAsync( + () => + TestRenderer.create( + + + + + + ), + false + ); + expect(getInspectedElementPath).not.toBeNull(); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot('1: Initially inspect element'); + + TestUtils.act(() => { + ReactDOM.render( + , + container + ); + }); + + inspectedElement = null; + + TestRenderer.act(() => { + TestUtils.act(() => { + getInspectedElementPath(id, ['props', 'nestedObject', 'a']); + jest.runOnlyPendingTimers(); + }); + }); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot('2: Inspect props.nestedObject.a'); + + done(); + }); +}); diff --git a/extension/src/__tests__/inspectedElementSerializer.js b/extension/src/__tests__/inspectedElementSerializer.js new file mode 100644 index 0000000000000..13e105cf0d62e --- /dev/null +++ b/extension/src/__tests__/inspectedElementSerializer.js @@ -0,0 +1,28 @@ +// test() is part of Jest's serializer API +export function test(maybeInspectedElement) { + return ( + maybeInspectedElement !== null && + typeof maybeInspectedElement === 'object' && + maybeInspectedElement.hasOwnProperty('canEditFunctionProps') && + maybeInspectedElement.hasOwnProperty('canEditHooks') && + maybeInspectedElement.hasOwnProperty('canToggleSuspense') && + maybeInspectedElement.hasOwnProperty('canViewSource') + ); +} + +// 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 + ); +} diff --git a/extension/src/__tests__/legacy/__snapshots__/inspectElement-test.js.snap b/extension/src/__tests__/legacy/__snapshots__/inspectElement-test.js.snap new file mode 100644 index 0000000000000..1cfc30e884442 --- /dev/null +++ b/extension/src/__tests__/legacy/__snapshots__/inspectElement-test.js.snap @@ -0,0 +1,186 @@ +// 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 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": { + "html_element": {}, + "fn": {}, + "symbol": {}, + "react_element": {}, + "array_buffer": {}, + "typed_array": {}, + "date": {} + }, + "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 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/extension/src/__tests__/legacy/__snapshots__/storeLegacy-v15-test.js.snap b/extension/src/__tests__/legacy/__snapshots__/storeLegacy-v15-test.js.snap new file mode 100644 index 0000000000000..06628a9e9a025 --- /dev/null +++ b/extension/src/__tests__/legacy/__snapshots__/storeLegacy-v15-test.js.snap @@ -0,0 +1,499 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Store (legacy) collapseNodesByDefault:false should not filter DOM nodes from the store tree: 1: mount 1`] = ` +[root] + ▾ + ▾
+ ▾
+ ▾ + ▾
+ ▾ +
+ ▾ + ▾
+ ▾ +
+ +`; + +exports[`Store (legacy) collapseNodesByDefault:false should not filter DOM nodes from the store tree: 2: update 1`] = ` +[root] + ▾ + ▾
+ ▾
+ ▾ + ▾
+ ▾ +
+ ▾ + ▾
+ ▾ +
+ +`; + +exports[`Store (legacy) collapseNodesByDefault:false should not filter DOM nodes from the store tree: 5: unmount 1`] = ``; + +exports[`Store (legacy) collapseNodesByDefault:false should support adding and removing children: 1: mount 1`] = ` +[root] + ▾ + ▾
+ ▾ +
+`; + +exports[`Store (legacy) collapseNodesByDefault:false should support adding and removing children: 2: add child 1`] = ` +[root] + ▾ + ▾
+ ▾ +
+ ▾ +
+`; + +exports[`Store (legacy) collapseNodesByDefault:false should support adding and removing children: 3: remove child 1`] = ` +[root] + ▾ + ▾
+ ▾ +
+`; + +exports[`Store (legacy) collapseNodesByDefault:false should support collapsing parts of the tree: 1: mount 1`] = ` +[root] + ▾ + ▾
+ ▾ + ▾
+ ▾ +
+ ▾ +
+ ▾ + ▾
+ ▾ +
+ ▾ +
+`; + +exports[`Store (legacy) collapseNodesByDefault:false should support collapsing parts of the tree: 2: collapse first Parent 1`] = ` +[root] + ▾ + ▾
+ ▸ + ▾ + ▾
+ ▾ +
+ ▾ +
+`; + +exports[`Store (legacy) collapseNodesByDefault:false should support collapsing parts of the tree: 3: collapse second Parent 1`] = ` +[root] + ▾ + ▾
+ ▸ + ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:false should support collapsing parts of the tree: 4: expand first Parent 1`] = ` +[root] + ▾ + ▾
+ ▾ + ▾
+ ▾ +
+ ▾ +
+ ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:false should support collapsing parts of the tree: 5: collapse Grandparent 1`] = ` +[root] + ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:false should support collapsing parts of the tree: 6: expand Grandparent 1`] = ` +[root] + ▾ + ▾
+ ▾ + ▾
+ ▾ +
+ ▾ +
+ ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:false should support mount and update operations for multiple roots: 1: mount 1`] = ` +[root] + ▾ + ▾
+ ▾ +
+ ▾ +
+ ▾ +
+[root] + ▾ + ▾
+ ▾ +
+ ▾ +
+`; + +exports[`Store (legacy) collapseNodesByDefault:false should support mount and update operations for multiple roots: 2: update 1`] = ` +[root] + ▾ + ▾
+ ▾ +
+ ▾ +
+ ▾ +
+ ▾ +
+[root] + ▾ + ▾
+ ▾ +
+`; + +exports[`Store (legacy) collapseNodesByDefault:false should support mount and update operations for multiple roots: 3: unmount B 1`] = ` +[root] + ▾ + ▾
+ ▾ +
+ ▾ +
+ ▾ +
+ ▾ +
+`; + +exports[`Store (legacy) collapseNodesByDefault:false should support mount and update operations for multiple roots: 4: unmount A 1`] = ``; + +exports[`Store (legacy) collapseNodesByDefault:false should support mount and update operations: 1: mount 1`] = ` +[root] + ▾ + ▾
+ ▾ + ▾
+ ▾ +
+ ▾ +
+ ▾ +
+ ▾ +
+ ▾ + ▾
+ ▾ +
+ ▾ +
+ ▾ +
+ ▾ +
+`; + +exports[`Store (legacy) collapseNodesByDefault:false should support mount and update operations: 2: update 1`] = ` +[root] + ▾ + ▾
+ ▾ + ▾
+ ▾ +
+ ▾ +
+ ▾ + ▾
+ ▾ +
+ ▾ +
+`; + +exports[`Store (legacy) collapseNodesByDefault:false should support mount and update operations: 3: unmount 1`] = ``; + +exports[`Store (legacy) collapseNodesByDefault:false should support reordering of children: 1: mount 1`] = ` +[root] + ▾ + ▾
+ ▾ + ▾
+ ▾ +
+ ▾ + ▾
+ ▾ +
+ ▾ +
+`; + +exports[`Store (legacy) collapseNodesByDefault:false should support reordering of children: 2: reorder children 1`] = ` +[root] + ▾ + ▾
+ ▾ + ▾
+ ▾ +
+ ▾ +
+ ▾ + ▾
+ ▾ +
+`; + +exports[`Store (legacy) collapseNodesByDefault:false should support reordering of children: 3: collapse root 1`] = ` +[root] + ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:false should support reordering of children: 4: expand root 1`] = ` +[root] + ▾ + ▾
+ ▾ + ▾
+ ▾ +
+ ▾ +
+ ▾ + ▾
+ ▾ +
+`; + +exports[`Store (legacy) collapseNodesByDefault:true should not filter DOM nodes from the store tree: 1: mount 1`] = ` +[root] + ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:true should not filter DOM nodes from the store tree: 2: expand Grandparent 1`] = ` +[root] + ▾ + ▸
+`; + +exports[`Store (legacy) collapseNodesByDefault:true should not filter DOM nodes from the store tree: 3: expand div 1`] = ` +[root] + ▾ + ▾
+ ▸
+ ▸ + +`; + +exports[`Store (legacy) collapseNodesByDefault:true should not filter DOM nodes from the store tree: 4: final update 1`] = ` +[root] + ▾ + ▾
+ ▸
+ ▸ + +`; + +exports[`Store (legacy) collapseNodesByDefault:true should not filter DOM nodes from the store tree: 5: unmount 1`] = ``; + +exports[`Store (legacy) collapseNodesByDefault:true should support expanding deep parts of the tree: 1: mount 1`] = ` +[root] + ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:true should support expanding deep parts of the tree: 2: expand deepest node 1`] = ` +[root] + ▾ + ▾ + ▾ + ▾ + ▾ +
+`; + +exports[`Store (legacy) collapseNodesByDefault:true should support expanding deep parts of the tree: 3: collapse root 1`] = ` +[root] + ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:true should support expanding deep parts of the tree: 4: expand root 1`] = ` +[root] + ▾ + ▾ + ▾ + ▾ + ▾ +
+`; + +exports[`Store (legacy) collapseNodesByDefault:true should support expanding deep parts of the tree: 5: collapse middle node 1`] = ` +[root] + ▾ + ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:true should support expanding deep parts of the tree: 6: expand middle node 1`] = ` +[root] + ▾ + ▾ + ▾ + ▾ + ▾ +
+`; + +exports[`Store (legacy) collapseNodesByDefault:true should support expanding parts of the tree: 1: mount 1`] = ` +[root] + ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:true should support expanding parts of the tree: 2: expand Grandparent 1`] = ` +[root] + ▾ + ▸
+`; + +exports[`Store (legacy) collapseNodesByDefault:true should support expanding parts of the tree: 3: expand parent div 1`] = ` +[root] + ▾ + ▾
+ ▸ + ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:true should support expanding parts of the tree: 4: expand first Parent 1`] = ` +[root] + ▾ + ▾
+ ▾ + ▸
+ ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:true should support expanding parts of the tree: 5: expand second Parent 1`] = ` +[root] + ▾ + ▾
+ ▾ + ▸
+ ▾ + ▸
+`; + +exports[`Store (legacy) collapseNodesByDefault:true should support expanding parts of the tree: 6: collapse first Parent 1`] = ` +[root] + ▾ + ▾
+ ▸ + ▾ + ▸
+`; + +exports[`Store (legacy) collapseNodesByDefault:true should support expanding parts of the tree: 7: collapse second Parent 1`] = ` +[root] + ▾ + ▾
+ ▸ + ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:true should support expanding parts of the tree: 8: collapse Grandparent 1`] = ` +[root] + ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:true should support mount and update operations for multiple roots: 1: mount 1`] = ` +[root] + ▸ +[root] + ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:true should support mount and update operations for multiple roots: 2: update 1`] = ` +[root] + ▸ +[root] + ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:true should support mount and update operations for multiple roots: 3: unmount B 1`] = ` +[root] + ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:true should support mount and update operations for multiple roots: 4: unmount A 1`] = ``; + +exports[`Store (legacy) collapseNodesByDefault:true should support mount and update operations: 1: mount 1`] = ` +[root] + ▸
+`; + +exports[`Store (legacy) collapseNodesByDefault:true should support mount and update operations: 2: update 1`] = ` +[root] + ▸
+`; + +exports[`Store (legacy) collapseNodesByDefault:true should support mount and update operations: 3: unmount 1`] = ``; + +exports[`Store (legacy) collapseNodesByDefault:true should support reordering of children: 1: mount 1`] = ` +[root] + ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:true should support reordering of children: 2: reorder children 1`] = ` +[root] + ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:true should support reordering of children: 3: expand root 1`] = ` +[root] + ▾ + ▸
+`; + +exports[`Store (legacy) collapseNodesByDefault:true should support reordering of children: 4: expand div 1`] = ` +[root] + ▾ + ▾
+ ▸ + ▸ +`; + +exports[`Store (legacy) collapseNodesByDefault:true should support reordering of children: 4: expand leaves 1`] = ` +[root] + ▾ + ▾
+ ▾ + ▸
+ ▾ + ▸
+`; + +exports[`Store (legacy) collapseNodesByDefault:true should support reordering of children: 5: collapse root 1`] = ` +[root] + ▸ +`; + +exports[`Store (legacy) should not allow a root node to be collapsed: 1: mount 1`] = ` +[root] + ▾ +
+`; diff --git a/extension/src/__tests__/legacy/inspectElement-test.js b/extension/src/__tests__/legacy/inspectElement-test.js new file mode 100644 index 0000000000000..7b8c2b04a9f52 --- /dev/null +++ b/extension/src/__tests__/legacy/inspectElement-test.js @@ -0,0 +1,283 @@ +// @flow + +import type { InspectedElementPayload } from 'src/backend/types'; +import type { DehydratedData } from 'src/devtools/views/Components/types'; +import type { FrontendBridge } from 'src/bridge'; +import type Store from 'src/devtools/store'; + +describe('InspectedElementContext', () => { + let React; + let ReactDOM; + let hydrate; + let meta; + let bridge: FrontendBridge; + let store: Store; + + 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); + } else { + return null; + } + } + + async function read( + id: number, + path?: Array + ): 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); + }; + + bridge.addListener('inspectedElement', onInspectedElement); + bridge.send('inspectElement', { id, path, rendererID }); + + jest.runOnlyPendingTimers(); + }); + } + + beforeEach(() => { + bridge = global.bridge; + store = global.store; + + hydrate = require('src/hydration').hydrate; + meta = require('src/hydration').meta; + + // 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"). + jest.mock('react', () => jest.requireActual('react-15/dist/react.js')); + jest.mock('react-dom', () => + jest.requireActual('react-dom-15/dist/react-dom.js') + ); + + React = require('react'); + ReactDOM = require('react-dom'); + }); + + it('should inspect the currently selected element', async done => { + const Example = () => null; + + act(() => + ReactDOM.render(, document.createElement('div')) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + const inspectedElement = await read(id); + + expect(inspectedElement).toMatchSnapshot('1: Initial inspection'); + + done(); + }); + + it('should support simple data types', async done => { + const Example = () => null; + + act(() => + ReactDOM.render( + , + document.createElement('div') + ) + ); + + 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(); + + done(); + }); + + it('should support complex data types', async done => { + const Example = () => null; + + const div = document.createElement('div'); + const exmapleFunction = () => {}; + const typedArray = new Uint8Array(3); + + act(() => + ReactDOM.render( + } + array_buffer={typedArray.buffer} + typed_array={typedArray} + date={new Date()} + />, + document.createElement('div') + ) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + const inspectedElement = await read(id); + + expect(inspectedElement).toMatchSnapshot('1: Initial inspection'); + + const { + html_element, + fn, + symbol, + react_element, + array_buffer, + typed_array, + date, + } = inspectedElement.value.props; + expect(html_element[meta.inspectable]).toBe(false); + expect(html_element[meta.name]).toBe('DIV'); + expect(html_element[meta.type]).toBe('html_element'); + expect(fn[meta.inspectable]).toBe(false); + expect(fn[meta.name]).toBe('exmapleFunction'); + expect(fn[meta.type]).toBe('function'); + expect(symbol[meta.inspectable]).toBe(false); + expect(symbol[meta.name]).toBe('Symbol(symbol)'); + expect(symbol[meta.type]).toBe('symbol'); + expect(react_element[meta.inspectable]).toBe(false); + expect(react_element[meta.name]).toBe('span'); + expect(react_element[meta.type]).toBe('react_element'); + expect(array_buffer[meta.size]).toBe(3); + expect(array_buffer[meta.inspectable]).toBe(false); + expect(array_buffer[meta.name]).toBe('ArrayBuffer'); + expect(array_buffer[meta.type]).toBe('array_buffer'); + expect(typed_array[meta.size]).toBe(3); + expect(typed_array[meta.inspectable]).toBe(false); + expect(typed_array[meta.name]).toBe('Uint8Array'); + expect(typed_array[meta.type]).toBe('typed_array'); + expect(date[meta.inspectable]).toBe(false); + expect(date[meta.type]).toBe('date'); + + done(); + }); + + it('should support custom objects with enumerable properties and getters', async done => { + class CustomData { + _number = 42; + get number() { + return this._number; + } + set number(value) { + this._number = value; + } + } + + const descriptor = ((Object.getOwnPropertyDescriptor( + CustomData.prototype, + 'number' + ): any): PropertyDescriptor); + descriptor.enumerable = true; + Object.defineProperty(CustomData.prototype, 'number', descriptor); + + const Example = ({ data }) => null; + + act(() => + ReactDOM.render( + , + document.createElement('div') + ) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + const inspectedElement = await read(id); + + expect(inspectedElement).toMatchSnapshot('1: Initial inspection'); + + done(); + }); + + it('should not dehydrate nested values until explicitly requested', async done => { + const Example = () => null; + + act(() => + ReactDOM.render( + , + document.createElement('div') + ) + ); + + 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'); + + 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', + 0, + 'd', + ]); + expect(inspectedElement).toMatchSnapshot( + '4: Inspect props.nestedObject.a.b.c.0.d' + ); + + done(); + }); +}); diff --git a/extension/src/__tests__/legacy/storeLegacy-v15-test.js b/extension/src/__tests__/legacy/storeLegacy-v15-test.js new file mode 100644 index 0000000000000..fc5d373e5bbd4 --- /dev/null +++ b/extension/src/__tests__/legacy/storeLegacy-v15-test.js @@ -0,0 +1,505 @@ +// @flow + +describe('Store (legacy)', () => { + let React; + let ReactDOM; + let store; + + const act = (callback: Function) => { + callback(); + + jest.runAllTimers(); // Flush Bridge operations + }; + + beforeEach(() => { + store = global.store; + + // 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"). + jest.mock('react', () => jest.requireActual('react-15/dist/react.js')); + jest.mock('react-dom', () => + jest.requireActual('react-dom-15/dist/react-dom.js') + ); + + React = require('react'); + ReactDOM = require('react-dom'); + }); + + it('should not allow a root node to be collapsed', () => { + const Component = () =>
Hi
; + + act(() => + ReactDOM.render(, document.createElement('div')) + ); + expect(store).toMatchSnapshot('1: mount'); + + expect(store.roots).toHaveLength(1); + + const rootID = store.roots[0]; + + expect(() => store.toggleIsCollapsed(rootID, true)).toThrow( + 'Root nodes cannot be collapsed' + ); + }); + + describe('collapseNodesByDefault:false', () => { + beforeEach(() => { + store.collapseNodesByDefault = false; + }); + + it('should support mount and update operations', () => { + const Grandparent = ({ count }) => ( +
+ + +
+ ); + const Parent = ({ count }) => ( +
+ {new Array(count).fill(true).map((_, index) => ( + + ))} +
+ ); + const Child = () =>
Hi!
; + + const container = document.createElement('div'); + + act(() => ReactDOM.render(, container)); + expect(store).toMatchSnapshot('1: mount'); + + act(() => ReactDOM.render(, container)); + expect(store).toMatchSnapshot('2: update'); + + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(store).toMatchSnapshot('3: unmount'); + }); + + it('should support mount and update operations for multiple roots', () => { + const Parent = ({ count }) => ( +
+ {new Array(count).fill(true).map((_, index) => ( + + ))} +
+ ); + const Child = () =>
Hi!
; + + const containerA = document.createElement('div'); + const containerB = document.createElement('div'); + + act(() => { + ReactDOM.render(, containerA); + ReactDOM.render(, containerB); + }); + expect(store).toMatchSnapshot('1: mount'); + + act(() => { + ReactDOM.render(, containerA); + ReactDOM.render(, containerB); + }); + expect(store).toMatchSnapshot('2: update'); + + act(() => ReactDOM.unmountComponentAtNode(containerB)); + expect(store).toMatchSnapshot('3: unmount B'); + + act(() => ReactDOM.unmountComponentAtNode(containerA)); + expect(store).toMatchSnapshot('4: unmount A'); + }); + + it('should not filter DOM nodes from the store tree', () => { + const Grandparent = ({ flip }) => ( +
+
+ +
+ + +
+ ); + const Parent = ({ flip }) => ( +
+ {flip ? 'foo' : null} + + {flip && [null, 'hello', 42]} + {flip ? 'bar' : 'baz'} +
+ ); + const Child = () =>
Hi!
; + const Nothing = () => null; + + const container = document.createElement('div'); + act(() => + ReactDOM.render(, container) + ); + expect(store).toMatchSnapshot('1: mount'); + + act(() => + ReactDOM.render(, container) + ); + expect(store).toMatchSnapshot('2: update'); + + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(store).toMatchSnapshot('5: unmount'); + }); + + it('should support collapsing parts of the tree', () => { + const Grandparent = ({ count }) => ( +
+ + +
+ ); + const Parent = ({ count }) => ( +
+ {new Array(count).fill(true).map((_, index) => ( + + ))} +
+ ); + const Child = () =>
Hi!
; + + act(() => + ReactDOM.render( + , + document.createElement('div') + ) + ); + expect(store).toMatchSnapshot('1: mount'); + + const grandparentID = store.getElementIDAtIndex(0); + const parentOneID = store.getElementIDAtIndex(2); + const parentTwoID = store.getElementIDAtIndex(8); + + act(() => store.toggleIsCollapsed(parentOneID, true)); + expect(store).toMatchSnapshot('2: collapse first Parent'); + + act(() => store.toggleIsCollapsed(parentTwoID, true)); + expect(store).toMatchSnapshot('3: collapse second Parent'); + + act(() => store.toggleIsCollapsed(parentOneID, false)); + expect(store).toMatchSnapshot('4: expand first Parent'); + + act(() => store.toggleIsCollapsed(grandparentID, true)); + expect(store).toMatchSnapshot('5: collapse Grandparent'); + + act(() => store.toggleIsCollapsed(grandparentID, false)); + expect(store).toMatchSnapshot('6: expand Grandparent'); + }); + + it('should support adding and removing children', () => { + const Root = ({ children }) =>
{children}
; + const Component = () =>
; + + const container = document.createElement('div'); + + act(() => + ReactDOM.render( + + + , + container + ) + ); + expect(store).toMatchSnapshot('1: mount'); + + act(() => + ReactDOM.render( + + + + , + container + ) + ); + expect(store).toMatchSnapshot('2: add child'); + + act(() => + ReactDOM.render( + + + , + container + ) + ); + expect(store).toMatchSnapshot('3: remove child'); + }); + + it('should support reordering of children', () => { + const Root = ({ children }) =>
{children}
; + const Component = () =>
; + + const Foo = () =>
{[]}
; + const Bar = () => ( +
{[, ]}
+ ); + const foo = ; + const bar = ; + + const container = document.createElement('div'); + + act(() => ReactDOM.render({[foo, bar]}, container)); + expect(store).toMatchSnapshot('1: mount'); + + act(() => ReactDOM.render({[bar, foo]}, container)); + expect(store).toMatchSnapshot('2: reorder children'); + + act(() => store.toggleIsCollapsed(store.getElementIDAtIndex(0), true)); + expect(store).toMatchSnapshot('3: collapse root'); + + act(() => store.toggleIsCollapsed(store.getElementIDAtIndex(0), false)); + expect(store).toMatchSnapshot('4: expand root'); + }); + }); + + describe('collapseNodesByDefault:true', () => { + beforeEach(() => { + store.collapseNodesByDefault = true; + }); + + it('should support mount and update operations', () => { + const Parent = ({ count }) => ( +
+ {new Array(count).fill(true).map((_, index) => ( + + ))} +
+ ); + const Child = () =>
Hi!
; + + const container = document.createElement('div'); + + act(() => + ReactDOM.render( +
+ + +
, + container + ) + ); + expect(store).toMatchSnapshot('1: mount'); + + act(() => + ReactDOM.render( +
+ + +
, + container + ) + ); + expect(store).toMatchSnapshot('2: update'); + + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(store).toMatchSnapshot('3: unmount'); + }); + + it('should support mount and update operations for multiple roots', () => { + const Parent = ({ count }) => ( +
+ {new Array(count).fill(true).map((_, index) => ( + + ))} +
+ ); + const Child = () =>
Hi!
; + + const containerA = document.createElement('div'); + const containerB = document.createElement('div'); + + act(() => { + ReactDOM.render(, containerA); + ReactDOM.render(, containerB); + }); + expect(store).toMatchSnapshot('1: mount'); + + act(() => { + ReactDOM.render(, containerA); + ReactDOM.render(, containerB); + }); + expect(store).toMatchSnapshot('2: update'); + + act(() => ReactDOM.unmountComponentAtNode(containerB)); + expect(store).toMatchSnapshot('3: unmount B'); + + act(() => ReactDOM.unmountComponentAtNode(containerA)); + expect(store).toMatchSnapshot('4: unmount A'); + }); + + it('should not filter DOM nodes from the store tree', () => { + const Grandparent = ({ flip }) => ( +
+
+ +
+ + +
+ ); + const Parent = ({ flip }) => ( +
+ {flip ? 'foo' : null} + + {flip && [null, 'hello', 42]} + {flip ? 'bar' : 'baz'} +
+ ); + const Child = () =>
Hi!
; + const Nothing = () => null; + + const container = document.createElement('div'); + act(() => + ReactDOM.render(, container) + ); + expect(store).toMatchSnapshot('1: mount'); + + act(() => store.toggleIsCollapsed(store.getElementIDAtIndex(0), false)); + expect(store).toMatchSnapshot('2: expand Grandparent'); + + act(() => store.toggleIsCollapsed(store.getElementIDAtIndex(1), false)); + expect(store).toMatchSnapshot('3: expand div'); + + act(() => + ReactDOM.render(, container) + ); + expect(store).toMatchSnapshot('4: final update'); + + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(store).toMatchSnapshot('5: unmount'); + }); + + it('should support expanding parts of the tree', () => { + const Grandparent = ({ count }) => ( +
+ + +
+ ); + const Parent = ({ count }) => ( +
+ {new Array(count).fill(true).map((_, index) => ( + + ))} +
+ ); + const Child = () =>
Hi!
; + + act(() => + ReactDOM.render( + , + document.createElement('div') + ) + ); + expect(store).toMatchSnapshot('1: mount'); + + const grandparentID = store.getElementIDAtIndex(0); + + act(() => store.toggleIsCollapsed(grandparentID, false)); + expect(store).toMatchSnapshot('2: expand Grandparent'); + + const parentDivID = store.getElementIDAtIndex(1); + act(() => store.toggleIsCollapsed(parentDivID, false)); + expect(store).toMatchSnapshot('3: expand parent div'); + + const parentOneID = store.getElementIDAtIndex(2); + const parentTwoID = store.getElementIDAtIndex(3); + + act(() => store.toggleIsCollapsed(parentOneID, false)); + expect(store).toMatchSnapshot('4: expand first Parent'); + + act(() => store.toggleIsCollapsed(parentTwoID, false)); + expect(store).toMatchSnapshot('5: expand second Parent'); + + act(() => store.toggleIsCollapsed(parentOneID, true)); + expect(store).toMatchSnapshot('6: collapse first Parent'); + + act(() => store.toggleIsCollapsed(parentTwoID, true)); + expect(store).toMatchSnapshot('7: collapse second Parent'); + + act(() => store.toggleIsCollapsed(grandparentID, true)); + expect(store).toMatchSnapshot('8: collapse Grandparent'); + }); + + it('should support expanding deep parts of the tree', () => { + const Wrapper = ({ forwardedRef }) => ( + + ); + const Nested = ({ depth, forwardedRef }) => + depth > 0 ? ( + + ) : ( +
+ ); + + let ref = null; + const refSetter = value => { + ref = value; + }; + + act(() => + ReactDOM.render( + , + document.createElement('div') + ) + ); + expect(store).toMatchSnapshot('1: mount'); + + const deepestedNodeID = global.agent.getIDForNode(ref); + + act(() => store.toggleIsCollapsed(deepestedNodeID, false)); + expect(store).toMatchSnapshot('2: expand deepest node'); + + const rootID = store.getElementIDAtIndex(0); + + act(() => store.toggleIsCollapsed(rootID, true)); + expect(store).toMatchSnapshot('3: collapse root'); + + act(() => store.toggleIsCollapsed(rootID, false)); + expect(store).toMatchSnapshot('4: expand root'); + + const id = store.getElementIDAtIndex(1); + + act(() => store.toggleIsCollapsed(id, true)); + expect(store).toMatchSnapshot('5: collapse middle node'); + + act(() => store.toggleIsCollapsed(id, false)); + expect(store).toMatchSnapshot('6: expand middle node'); + }); + + it('should support reordering of children', () => { + const Root = ({ children }) =>
{children}
; + const Component = () =>
; + + const Foo = () =>
{[]}
; + const Bar = () => ( +
{[, ]}
+ ); + const foo = ; + const bar = ; + + const container = document.createElement('div'); + + act(() => ReactDOM.render({[foo, bar]}, container)); + expect(store).toMatchSnapshot('1: mount'); + + act(() => ReactDOM.render({[bar, foo]}, container)); + expect(store).toMatchSnapshot('2: reorder children'); + + act(() => store.toggleIsCollapsed(store.getElementIDAtIndex(0), false)); + expect(store).toMatchSnapshot('3: expand root'); + + act(() => store.toggleIsCollapsed(store.getElementIDAtIndex(1), false)); + expect(store).toMatchSnapshot('4: expand div'); + + act(() => { + store.toggleIsCollapsed(store.getElementIDAtIndex(3), false); + store.toggleIsCollapsed(store.getElementIDAtIndex(2), false); + }); + expect(store).toMatchSnapshot('4: expand leaves'); + + act(() => store.toggleIsCollapsed(store.getElementIDAtIndex(0), true)); + expect(store).toMatchSnapshot('5: collapse root'); + }); + }); +}); diff --git a/extension/src/__tests__/ownersListContext-test.js b/extension/src/__tests__/ownersListContext-test.js new file mode 100644 index 0000000000000..97681b70bdde5 --- /dev/null +++ b/extension/src/__tests__/ownersListContext-test.js @@ -0,0 +1,200 @@ +// @flow + +import typeof ReactTestRenderer from 'react-test-renderer'; +import type { Element } from 'src/devtools/views/Components/types'; +import type { FrontendBridge } from 'src/bridge'; +import type Store from 'src/devtools/store'; + +describe('OwnersListContext', () => { + let React; + let ReactDOM; + let TestRenderer: ReactTestRenderer; + let bridge: FrontendBridge; + let store: Store; + let utils; + + let BridgeContext; + let OwnersListContext; + let OwnersListContextController; + let StoreContext; + let TreeContextController; + + beforeEach(() => { + utils = require('./utils'); + utils.beforeEachProfiling(); + + bridge = global.bridge; + store = global.store; + store.collapseNodesByDefault = false; + + React = require('react'); + ReactDOM = require('react-dom'); + TestRenderer = utils.requireTestRenderer(); + + BridgeContext = require('src/devtools/views/context').BridgeContext; + OwnersListContext = require('src/devtools/views/Components/OwnersListContext') + .OwnersListContext; + OwnersListContextController = require('src/devtools/views/Components/OwnersListContext') + .OwnersListContextController; + StoreContext = require('src/devtools/views/context').StoreContext; + TreeContextController = require('src/devtools/views/Components/TreeContext') + .TreeContextController; + }); + + const Contexts = ({ children, defaultOwnerID = null }) => ( + + + + {children} + + + + ); + + it('should fetch the owners list for the selected element', async done => { + const Grandparent = () => ; + const Parent = () => { + return ( + + + + + ); + }; + const Child = () => null; + + utils.act(() => + ReactDOM.render(, document.createElement('div')) + ); + + expect(store).toMatchSnapshot('mount'); + + const parent = ((store.getElementAtIndex(1): any): Element); + const firstChild = ((store.getElementAtIndex(2): any): Element); + + let didFinish = false; + + function Suspender({ owner }) { + const read = React.useContext(OwnersListContext); + const owners = read(owner.id); + expect(owners).toMatchSnapshot( + `owners for "${(owner && owner.displayName) || ''}"` + ); + didFinish = true; + return null; + } + + await utils.actAsync(() => + TestRenderer.create( + + + + + + ) + ); + expect(didFinish).toBe(true); + + didFinish = false; + await utils.actAsync(() => + TestRenderer.create( + + + + + + ) + ); + expect(didFinish).toBe(true); + + done(); + }); + + it('should fetch the owners list for the selected element that includes filtered components', async done => { + store.componentFilters = [utils.createDisplayNameFilter('^Parent$')]; + + const Grandparent = () => ; + const Parent = () => { + return ( + + + + + ); + }; + const Child = () => null; + + utils.act(() => + ReactDOM.render(, document.createElement('div')) + ); + + expect(store).toMatchSnapshot('mount'); + + const firstChild = ((store.getElementAtIndex(1): any): Element); + + let didFinish = false; + + function Suspender({ owner }) { + const read = React.useContext(OwnersListContext); + const owners = read(owner.id); + expect(owners).toMatchSnapshot( + `owners for "${(owner && owner.displayName) || ''}"` + ); + didFinish = true; + return null; + } + + await utils.actAsync(() => + TestRenderer.create( + + + + + + ) + ); + expect(didFinish).toBe(true); + + done(); + }); + + it('should include the current element even if there are no other owners', async done => { + store.componentFilters = [utils.createDisplayNameFilter('^Parent$')]; + + const Grandparent = () => ; + const Parent = () => null; + + utils.act(() => + ReactDOM.render(, document.createElement('div')) + ); + + expect(store).toMatchSnapshot('mount'); + + const grandparent = ((store.getElementAtIndex(0): any): Element); + + let didFinish = false; + + function Suspender({ owner }) { + const read = React.useContext(OwnersListContext); + const owners = read(owner.id); + expect(owners).toMatchSnapshot( + `owners for "${(owner && owner.displayName) || ''}"` + ); + didFinish = true; + return null; + } + + await utils.actAsync(() => + TestRenderer.create( + + + + + + ) + ); + expect(didFinish).toBe(true); + + done(); + }); +}); diff --git a/extension/src/__tests__/profilerContext-test.js b/extension/src/__tests__/profilerContext-test.js new file mode 100644 index 0000000000000..c06f70d0390d2 --- /dev/null +++ b/extension/src/__tests__/profilerContext-test.js @@ -0,0 +1,348 @@ +// @flow + +import typeof ReactTestRenderer from 'react-test-renderer'; +import type { FrontendBridge } from 'src/bridge'; +import type { Context } from 'src/devtools/views/Profiler/ProfilerContext'; +import type { DispatcherContext } from 'src/devtools/views/Components/TreeContext'; +import type Store from 'src/devtools/store'; + +describe('ProfilerContext', () => { + let React; + let ReactDOM; + let TestRenderer: ReactTestRenderer; + let bridge: FrontendBridge; + let store: Store; + let utils; + + let BridgeContext; + let ProfilerContext; + let ProfilerContextController; + let StoreContext; + let TreeContextController; + let TreeDispatcherContext; + let TreeStateContext; + + beforeEach(() => { + utils = require('./utils'); + utils.beforeEachProfiling(); + + bridge = global.bridge; + store = global.store; + store.collapseNodesByDefault = false; + store.recordChangeDescriptions = true; + + React = require('react'); + ReactDOM = require('react-dom'); + TestRenderer = utils.requireTestRenderer(); + + BridgeContext = require('src/devtools/views/context').BridgeContext; + ProfilerContext = require('src/devtools/views/Profiler/ProfilerContext') + .ProfilerContext; + ProfilerContextController = require('src/devtools/views/Profiler/ProfilerContext') + .ProfilerContextController; + StoreContext = require('src/devtools/views/context').StoreContext; + TreeContextController = require('src/devtools/views/Components/TreeContext') + .TreeContextController; + TreeDispatcherContext = require('src/devtools/views/Components/TreeContext') + .TreeDispatcherContext; + TreeStateContext = require('src/devtools/views/Components/TreeContext') + .TreeStateContext; + }); + + const Contexts = ({ + children = null, + defaultSelectedElementID = null, + defaultSelectedElementIndex = null, + }: any) => ( + + + + {children} + + + + ); + + it('updates updates profiling support based on the attached roots', async done => { + const Component = () => null; + + let context: Context = ((null: any): Context); + + function ContextReader() { + context = React.useContext(ProfilerContext); + return null; + } + await utils.actAsync(() => { + TestRenderer.create( + + + + ); + }); + + expect(context.supportsProfiling).toBe(false); + + const containerA = document.createElement('div'); + const containerB = document.createElement('div'); + + await utils.actAsync(() => ReactDOM.render(, containerA)); + expect(context.supportsProfiling).toBe(true); + + await utils.actAsync(() => ReactDOM.render(, containerB)); + await utils.actAsync(() => ReactDOM.unmountComponentAtNode(containerA)); + expect(context.supportsProfiling).toBe(true); + + await utils.actAsync(() => ReactDOM.unmountComponentAtNode(containerB)); + expect(context.supportsProfiling).toBe(false); + + done(); + }); + + it('should gracefully handle an empty profiling session (with no recorded commits)', async done => { + const Example = () => null; + + utils.act(() => + ReactDOM.render(, document.createElement('div')) + ); + + let context: Context = ((null: any): Context); + + function ContextReader() { + context = React.useContext(ProfilerContext); + return null; + } + + // Profile but don't record any updates. + await utils.actAsync(() => store.profilerStore.startProfiling()); + await utils.actAsync(() => { + TestRenderer.create( + + + + ); + }); + expect(context).not.toBeNull(); + expect(context.didRecordCommits).toBe(false); + expect(context.isProcessingData).toBe(false); + expect(context.isProfiling).toBe(true); + expect(context.profilingData).toBe(null); + await utils.actAsync(() => store.profilerStore.stopProfiling()); + + expect(context).not.toBeNull(); + expect(context.didRecordCommits).toBe(false); + expect(context.isProcessingData).toBe(false); + expect(context.isProfiling).toBe(false); + expect(context.profilingData).not.toBe(null); + + done(); + }); + + it('should auto-select the root ID matching the Components tab selection if it has profiling data', async done => { + const Parent = () => ; + const Child = () => null; + + const containerOne = document.createElement('div'); + const containerTwo = document.createElement('div'); + utils.act(() => ReactDOM.render(, containerOne)); + utils.act(() => ReactDOM.render(, containerTwo)); + expect(store).toMatchSnapshot('mounted'); + + // Profile and record updates to both roots. + await utils.actAsync(() => store.profilerStore.startProfiling()); + await utils.actAsync(() => ReactDOM.render(, containerOne)); + await utils.actAsync(() => ReactDOM.render(, containerTwo)); + await utils.actAsync(() => store.profilerStore.stopProfiling()); + + let context: Context = ((null: any): Context); + function ContextReader() { + context = React.useContext(ProfilerContext); + return null; + } + + // Select an element within the second root. + await utils.actAsync(() => + TestRenderer.create( + + + + ) + ); + + expect(context).not.toBeNull(); + expect(context.rootID).toBe( + store.getRootIDForElement(((store.getElementIDAtIndex(3): any): number)) + ); + + done(); + }); + + it('should not select the root ID matching the Components tab selection if it has no profiling data', async done => { + const Parent = () => ; + const Child = () => null; + + const containerOne = document.createElement('div'); + const containerTwo = document.createElement('div'); + utils.act(() => ReactDOM.render(, containerOne)); + utils.act(() => ReactDOM.render(, containerTwo)); + expect(store).toMatchSnapshot('mounted'); + + // Profile and record updates to only the first root. + await utils.actAsync(() => store.profilerStore.startProfiling()); + await utils.actAsync(() => ReactDOM.render(, containerOne)); + await utils.actAsync(() => store.profilerStore.stopProfiling()); + + let context: Context = ((null: any): Context); + function ContextReader() { + context = React.useContext(ProfilerContext); + return null; + } + + // Select an element within the second root. + await utils.actAsync(() => + TestRenderer.create( + + + + ) + ); + + // Verify the default profiling root is the first one. + expect(context).not.toBeNull(); + expect(context.rootID).toBe( + store.getRootIDForElement(((store.getElementIDAtIndex(0): any): number)) + ); + + done(); + }); + + it('should maintain root selection between profiling sessions so long as there is data for that root', async done => { + const Parent = () => ; + const Child = () => null; + + const containerA = document.createElement('div'); + const containerB = document.createElement('div'); + utils.act(() => ReactDOM.render(, containerA)); + utils.act(() => ReactDOM.render(, containerB)); + expect(store).toMatchSnapshot('mounted'); + + // Profile and record updates. + await utils.actAsync(() => store.profilerStore.startProfiling()); + await utils.actAsync(() => ReactDOM.render(, containerA)); + await utils.actAsync(() => ReactDOM.render(, containerB)); + await utils.actAsync(() => store.profilerStore.stopProfiling()); + + let context: Context = ((null: any): Context); + let dispatch: DispatcherContext = ((null: any): DispatcherContext); + let selectedElementID = null; + function ContextReader() { + context = React.useContext(ProfilerContext); + dispatch = React.useContext(TreeDispatcherContext); + selectedElementID = React.useContext(TreeStateContext).selectedElementID; + return null; + } + + const id = ((store.getElementIDAtIndex(3): any): number); + + // Select an element within the second root. + await utils.actAsync(() => + TestRenderer.create( + + + + ) + ); + + expect(selectedElementID).toBe(id); + + // Profile and record more updates to both roots + await utils.actAsync(() => store.profilerStore.startProfiling()); + await utils.actAsync(() => ReactDOM.render(, containerA)); + await utils.actAsync(() => ReactDOM.render(, containerB)); + await utils.actAsync(() => store.profilerStore.stopProfiling()); + + const otherID = ((store.getElementIDAtIndex(0): any): number); + + // Change the selected element within a the Components tab. + utils.act(() => dispatch({ type: 'SELECT_ELEMENT_AT_INDEX', payload: 0 })); + + // Verify that the initial Profiler root selection is maintained. + expect(selectedElementID).toBe(otherID); + expect(context).not.toBeNull(); + expect(context.rootID).toBe(store.getRootIDForElement(id)); + + done(); + }); + + it('should sync selected element in the Components tab too, provided the element is a match', async done => { + const GrandParent = ({ includeChild }) => ( + + ); + const Parent = ({ includeChild }) => (includeChild ? : null); + const Child = () => null; + + const container = document.createElement('div'); + utils.act(() => + ReactDOM.render(, container) + ); + expect(store).toMatchSnapshot('mounted'); + + const parentID = ((store.getElementIDAtIndex(1): any): number); + const childID = ((store.getElementIDAtIndex(2): any): number); + + // Profile and record updates. + await utils.actAsync(() => store.profilerStore.startProfiling()); + await utils.actAsync(() => + ReactDOM.render(, container) + ); + await utils.actAsync(() => + ReactDOM.render(, container) + ); + await utils.actAsync(() => store.profilerStore.stopProfiling()); + + expect(store).toMatchSnapshot('updated'); + + let context: Context = ((null: any): Context); + let selectedElementID = null; + function ContextReader() { + context = React.useContext(ProfilerContext); + selectedElementID = React.useContext(TreeStateContext).selectedElementID; + return null; + } + + await utils.actAsync(() => + TestRenderer.create( + + + + ) + ); + expect(selectedElementID).toBeNull(); + + // Select an element in the Profiler tab and verify that the selection is synced to the Components tab. + await utils.actAsync(() => context.selectFiber(parentID, 'Parent')); + expect(selectedElementID).toBe(parentID); + + // We expect a "no element found" warning. + // Let's hide it from the test console though. + spyOn(console, 'warn'); + + // Select an unmounted element and verify no Components tab selection doesn't change. + await utils.actAsync(() => context.selectFiber(childID, 'Child')); + expect(selectedElementID).toBe(parentID); + + expect(console.warn).toHaveBeenCalledWith( + `No element found with id "${childID}"` + ); + + done(); + }); +}); diff --git a/extension/src/__tests__/profilerStore-test.js b/extension/src/__tests__/profilerStore-test.js new file mode 100644 index 0000000000000..0f4074f165b66 --- /dev/null +++ b/extension/src/__tests__/profilerStore-test.js @@ -0,0 +1,75 @@ +// @flow + +import type Store from 'src/devtools/store'; + +describe('ProfilerStore', () => { + let React; + let ReactDOM; + let store: Store; + let utils; + + beforeEach(() => { + utils = require('./utils'); + utils.beforeEachProfiling(); + + store = global.store; + store.collapseNodesByDefault = false; + store.recordChangeDescriptions = true; + + React = require('react'); + ReactDOM = require('react-dom'); + }); + + it('should not remove profiling data when roots are unmounted', async () => { + const Parent = ({ count }) => + new Array(count) + .fill(true) + .map((_, index) => ); + const Child = () =>
Hi!
; + + const containerA = document.createElement('div'); + const containerB = document.createElement('div'); + + utils.act(() => { + ReactDOM.render(, containerA); + ReactDOM.render(, containerB); + }); + + utils.act(() => store.profilerStore.startProfiling()); + + utils.act(() => { + ReactDOM.render(, containerA); + ReactDOM.render(, containerB); + }); + + utils.act(() => store.profilerStore.stopProfiling()); + + const rootA = store.roots[0]; + const rootB = store.roots[1]; + + utils.act(() => ReactDOM.unmountComponentAtNode(containerB)); + + expect(store.profilerStore.getDataForRoot(rootA)).not.toBeNull(); + + utils.act(() => ReactDOM.unmountComponentAtNode(containerA)); + + expect(store.profilerStore.getDataForRoot(rootB)).not.toBeNull(); + }); + + it('should not allow new/saved profiling data to be set while profiling is in progress', () => { + utils.act(() => store.profilerStore.startProfiling()); + const fauxProfilingData = { + dataForRoots: new Map(), + }; + spyOn(console, 'warn'); + store.profilerStore.profilingData = fauxProfilingData; + expect(store.profilerStore.profilingData).not.toBe(fauxProfilingData); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + 'Profiling data cannot be updated while profiling is in progress.' + ); + utils.act(() => store.profilerStore.stopProfiling()); + store.profilerStore.profilingData = fauxProfilingData; + expect(store.profilerStore.profilingData).toBe(fauxProfilingData); + }); +}); diff --git a/extension/src/__tests__/profilingCache-test.js b/extension/src/__tests__/profilingCache-test.js new file mode 100644 index 0000000000000..6f6e4742cda4f --- /dev/null +++ b/extension/src/__tests__/profilingCache-test.js @@ -0,0 +1,569 @@ +// @flow + +import typeof ReactTestRenderer from 'react-test-renderer'; +import type { FrontendBridge } from 'src/bridge'; +import type Store from 'src/devtools/store'; + +describe('ProfilingCache', () => { + let PropTypes; + let React; + let ReactDOM; + let Scheduler; + let SchedulerTracing; + let TestRenderer: ReactTestRenderer; + let bridge: FrontendBridge; + let store: Store; + let utils; + + beforeEach(() => { + utils = require('./utils'); + utils.beforeEachProfiling(); + + bridge = global.bridge; + store = global.store; + store.collapseNodesByDefault = false; + store.recordChangeDescriptions = true; + + PropTypes = require('prop-types'); + React = require('react'); + ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); + SchedulerTracing = require('scheduler/tracing'); + TestRenderer = utils.requireTestRenderer(); + }); + + it('should collect data for each root (including ones added or mounted after profiling started)', () => { + const Parent = ({ count }) => { + Scheduler.unstable_advanceTime(10); + const children = new Array(count) + .fill(true) + .map((_, index) => ); + return ( + + {children} + + + ); + }; + const Child = ({ duration }) => { + Scheduler.unstable_advanceTime(duration); + return null; + }; + const MemoizedChild = React.memo(Child); + + const containerA = document.createElement('div'); + const containerB = document.createElement('div'); + const containerC = document.createElement('div'); + + utils.act(() => ReactDOM.render(, containerA)); + utils.act(() => ReactDOM.render(, containerB)); + utils.act(() => store.profilerStore.startProfiling()); + utils.act(() => ReactDOM.render(, containerA)); + utils.act(() => ReactDOM.render(, containerC)); + utils.act(() => ReactDOM.render(, containerA)); + utils.act(() => ReactDOM.unmountComponentAtNode(containerB)); + utils.act(() => ReactDOM.render(, containerA)); + utils.act(() => store.profilerStore.stopProfiling()); + + let allProfilingDataForRoots = []; + + function Validator({ previousProfilingDataForRoot, rootID }) { + const profilingDataForRoot = store.profilerStore.getDataForRoot(rootID); + if (previousProfilingDataForRoot != null) { + expect(profilingDataForRoot).toEqual(previousProfilingDataForRoot); + } else { + expect(profilingDataForRoot).toMatchSnapshot( + `Data for root ${profilingDataForRoot.displayName}` + ); + } + allProfilingDataForRoots.push(profilingDataForRoot); + return null; + } + + const dataForRoots = + store.profilerStore.profilingData !== null + ? store.profilerStore.profilingData.dataForRoots + : null; + + expect(dataForRoots).not.toBeNull(); + + if (dataForRoots !== null) { + dataForRoots.forEach(dataForRoot => { + utils.act(() => + TestRenderer.create( + + ) + ); + }); + } + + expect(allProfilingDataForRoots).toHaveLength(3); + + utils.exportImportHelper(bridge, store); + + allProfilingDataForRoots.forEach(profilingDataForRoot => { + utils.act(() => + TestRenderer.create( + + ) + ); + }); + }); + + it('should collect data for each commit', () => { + const Parent = ({ count }) => { + Scheduler.unstable_advanceTime(10); + const children = new Array(count) + .fill(true) + .map((_, index) => ); + return ( + + {children} + + + ); + }; + const Child = ({ duration }) => { + Scheduler.unstable_advanceTime(duration); + return null; + }; + const MemoizedChild = React.memo(Child); + + const container = document.createElement('div'); + + utils.act(() => store.profilerStore.startProfiling()); + utils.act(() => ReactDOM.render(, container)); + utils.act(() => ReactDOM.render(, container)); + utils.act(() => ReactDOM.render(, container)); + utils.act(() => ReactDOM.render(, container)); + utils.act(() => store.profilerStore.stopProfiling()); + + const allCommitData = []; + + function Validator({ commitIndex, previousCommitDetails, rootID }) { + const commitData = store.profilerStore.getCommitData(rootID, commitIndex); + if (previousCommitDetails != null) { + expect(commitData).toEqual(previousCommitDetails); + } else { + allCommitData.push(commitData); + expect(commitData).toMatchSnapshot( + `CommitDetails commitIndex: ${commitIndex}` + ); + } + return null; + } + + const rootID = store.roots[0]; + + for (let commitIndex = 0; commitIndex < 4; commitIndex++) { + utils.act(() => { + TestRenderer.create( + + ); + }); + } + + expect(allCommitData).toHaveLength(4); + + utils.exportImportHelper(bridge, store); + + for (let commitIndex = 0; commitIndex < 4; commitIndex++) { + utils.act(() => { + TestRenderer.create( + + ); + }); + } + }); + + it('should record changed props/state/context/hooks', () => { + let instance = null; + + const ModernContext = React.createContext(0); + + class LegacyContextProvider extends React.Component< + any, + {| count: number |} + > { + static childContextTypes = { + count: PropTypes.number, + }; + state = { count: 0 }; + getChildContext() { + return this.state; + } + render() { + instance = this; + return ( + + + + + + + ); + } + } + + const FunctionComponentWithHooks = ({ count }) => { + React.useMemo(() => count, [count]); + return null; + }; + + class ModernContextConsumer extends React.Component { + static contextType = ModernContext; + render() { + return ; + } + } + + class LegacyContextConsumer extends React.Component { + static contextTypes = { + count: PropTypes.number, + }; + render() { + return ; + } + } + + const container = document.createElement('div'); + + utils.act(() => store.profilerStore.startProfiling()); + utils.act(() => ReactDOM.render(, container)); + expect(instance).not.toBeNull(); + utils.act(() => (instance: any).setState({ count: 1 })); + utils.act(() => + ReactDOM.render(, container) + ); + utils.act(() => + ReactDOM.render(, container) + ); + utils.act(() => ReactDOM.render(, container)); + utils.act(() => store.profilerStore.stopProfiling()); + + const allCommitData = []; + + function Validator({ commitIndex, previousCommitDetails, rootID }) { + const commitData = store.profilerStore.getCommitData(rootID, commitIndex); + if (previousCommitDetails != null) { + expect(commitData).toEqual(previousCommitDetails); + } else { + allCommitData.push(commitData); + expect(commitData).toMatchSnapshot( + `CommitDetails commitIndex: ${commitIndex}` + ); + } + return null; + } + + const rootID = store.roots[0]; + + for (let commitIndex = 0; commitIndex < 5; commitIndex++) { + utils.act(() => { + TestRenderer.create( + + ); + }); + } + + expect(allCommitData).toHaveLength(5); + + utils.exportImportHelper(bridge, store); + + for (let commitIndex = 0; commitIndex < 5; commitIndex++) { + utils.act(() => { + TestRenderer.create( + + ); + }); + } + }); + + it('should calculate a self duration based on actual children (not filtered children)', () => { + store.componentFilters = [utils.createDisplayNameFilter('^Parent$')]; + + const Grandparent = () => { + Scheduler.unstable_advanceTime(10); + return ( + + + + + ); + }; + const Parent = () => { + Scheduler.unstable_advanceTime(2); + return ; + }; + const Child = () => { + Scheduler.unstable_advanceTime(1); + return null; + }; + + utils.act(() => store.profilerStore.startProfiling()); + utils.act(() => + ReactDOM.render(, document.createElement('div')) + ); + utils.act(() => store.profilerStore.stopProfiling()); + + let commitData = null; + + function Validator({ commitIndex, rootID }) { + commitData = store.profilerStore.getCommitData(rootID, commitIndex); + expect(commitData).toMatchSnapshot( + `CommitDetails with filtered self durations` + ); + return null; + } + + const rootID = store.roots[0]; + + utils.act(() => { + TestRenderer.create(); + }); + + expect(commitData).not.toBeNull(); + }); + + it('should calculate self duration correctly for suspended views', async done => { + let data; + const getData = () => { + if (data) { + return data; + } else { + throw new Promise(resolve => { + data = 'abc'; + resolve(data); + }); + } + }; + + const Parent = () => { + Scheduler.unstable_advanceTime(10); + return ( + }> + + + ); + }; + const Fallback = () => { + Scheduler.unstable_advanceTime(2); + return 'Fallback...'; + }; + const Async = () => { + Scheduler.unstable_advanceTime(3); + const data = getData(); + return data; + }; + + utils.act(() => store.profilerStore.startProfiling()); + await utils.actAsync(() => + ReactDOM.render(, document.createElement('div')) + ); + utils.act(() => store.profilerStore.stopProfiling()); + + const allCommitData = []; + + function Validator({ commitIndex, rootID }) { + const commitData = store.profilerStore.getCommitData(rootID, commitIndex); + allCommitData.push(commitData); + expect(commitData).toMatchSnapshot( + `CommitDetails with filtered self durations` + ); + return null; + } + + const rootID = store.roots[0]; + + for (let commitIndex = 0; commitIndex < 2; commitIndex++) { + utils.act(() => { + TestRenderer.create( + + ); + }); + } + + expect(allCommitData).toHaveLength(2); + + done(); + }); + + it('should collect data for each rendered fiber', () => { + const Parent = ({ count }) => { + Scheduler.unstable_advanceTime(10); + const children = new Array(count) + .fill(true) + .map((_, index) => ); + return ( + + {children} + + + ); + }; + const Child = ({ duration }) => { + Scheduler.unstable_advanceTime(duration); + return null; + }; + const MemoizedChild = React.memo(Child); + + const container = document.createElement('div'); + + utils.act(() => store.profilerStore.startProfiling()); + utils.act(() => ReactDOM.render(, container)); + utils.act(() => ReactDOM.render(, container)); + utils.act(() => ReactDOM.render(, container)); + utils.act(() => store.profilerStore.stopProfiling()); + + const allFiberCommits = []; + + function Validator({ fiberID, previousFiberCommits, rootID }) { + const fiberCommits = store.profilerStore.profilingCache.getFiberCommits({ + fiberID, + rootID, + }); + if (previousFiberCommits != null) { + expect(fiberCommits).toEqual(previousFiberCommits); + } else { + allFiberCommits.push(fiberCommits); + expect(fiberCommits).toMatchSnapshot( + `FiberCommits: element ${fiberID}` + ); + } + return null; + } + + const rootID = store.roots[0]; + + for (let index = 0; index < store.numElements; index++) { + utils.act(() => { + const fiberID = store.getElementIDAtIndex(index); + if (fiberID == null) { + throw Error(`Unexpected null ID for element at index ${index}`); + } + TestRenderer.create( + + ); + }); + } + + expect(allFiberCommits).toHaveLength(store.numElements); + + utils.exportImportHelper(bridge, store); + + for (let index = 0; index < store.numElements; index++) { + utils.act(() => { + const fiberID = store.getElementIDAtIndex(index); + if (fiberID == null) { + throw Error(`Unexpected null ID for element at index ${index}`); + } + TestRenderer.create( + + ); + }); + } + }); + + it('should report every traced interaction', () => { + const Parent = ({ count }) => { + Scheduler.unstable_advanceTime(10); + const children = new Array(count) + .fill(true) + .map((_, index) => ); + return ( + + {children} + + + ); + }; + const Child = ({ duration }) => { + Scheduler.unstable_advanceTime(duration); + return null; + }; + const MemoizedChild = React.memo(Child); + + const container = document.createElement('div'); + + utils.act(() => store.profilerStore.startProfiling()); + utils.act(() => + SchedulerTracing.unstable_trace( + 'mount: one child', + Scheduler.unstable_now(), + () => ReactDOM.render(, container) + ) + ); + utils.act(() => + SchedulerTracing.unstable_trace( + 'update: two children', + Scheduler.unstable_now(), + () => ReactDOM.render(, container) + ) + ); + utils.act(() => store.profilerStore.stopProfiling()); + + let interactions = null; + + function Validator({ previousInteractions, rootID }) { + interactions = store.profilerStore.profilingCache.getInteractionsChartData( + { + rootID, + } + ).interactions; + if (previousInteractions != null) { + expect(interactions).toEqual(previousInteractions); + } else { + expect(interactions).toMatchSnapshot('Interactions'); + } + return null; + } + + const rootID = store.roots[0]; + + utils.act(() => + TestRenderer.create( + + ) + ); + + expect(interactions).not.toBeNull(); + + utils.exportImportHelper(bridge, store); + + utils.act(() => + TestRenderer.create( + + ) + ); + }); +}); diff --git a/extension/src/__tests__/profilingCharts-test.js b/extension/src/__tests__/profilingCharts-test.js new file mode 100644 index 0000000000000..0e7fff951e940 --- /dev/null +++ b/extension/src/__tests__/profilingCharts-test.js @@ -0,0 +1,242 @@ +// @flow + +import typeof TestRendererType from 'react-test-renderer'; +import type Store from 'src/devtools/store'; + +describe('profiling charts', () => { + let React; + let ReactDOM; + let Scheduler; + let SchedulerTracing; + let TestRenderer: TestRendererType; + let store: Store; + let utils; + + beforeEach(() => { + utils = require('./utils'); + utils.beforeEachProfiling(); + + store = global.store; + store.collapseNodesByDefault = false; + store.recordChangeDescriptions = true; + + React = require('react'); + ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); + SchedulerTracing = require('scheduler/tracing'); + TestRenderer = utils.requireTestRenderer(); + }); + + describe('flamegraph chart', () => { + it('should contain valid data', () => { + const Parent = (_: {||}) => { + Scheduler.unstable_advanceTime(10); + return ( + + + + + + ); + }; + + // Memoize children to verify that chart doesn't include in the update. + const Child = React.memo(function Child({ duration }) { + Scheduler.unstable_advanceTime(duration); + return null; + }); + + const container = document.createElement('div'); + + utils.act(() => store.profilerStore.startProfiling()); + utils.act(() => + SchedulerTracing.unstable_trace('mount', Scheduler.unstable_now(), () => + ReactDOM.render(, container) + ) + ); + utils.act(() => + SchedulerTracing.unstable_trace( + 'update', + Scheduler.unstable_now(), + () => ReactDOM.render(, container) + ) + ); + utils.act(() => store.profilerStore.stopProfiling()); + + let renderFinished = false; + + function Validator({ commitIndex, rootID }) { + const commitTree = store.profilerStore.profilingCache.getCommitTree({ + commitIndex, + rootID, + }); + const chartData = store.profilerStore.profilingCache.getFlamegraphChartData( + { + commitIndex, + commitTree, + rootID, + } + ); + expect(commitTree).toMatchSnapshot(`${commitIndex}: CommitTree`); + expect(chartData).toMatchSnapshot( + `${commitIndex}: FlamegraphChartData` + ); + renderFinished = true; + return null; + } + + const rootID = store.roots[0]; + + for (let commitIndex = 0; commitIndex < 2; commitIndex++) { + renderFinished = false; + + utils.act(() => { + TestRenderer.create( + + ); + }); + + expect(renderFinished).toBe(true); + } + + expect(renderFinished).toBe(true); + }); + }); + + describe('ranked chart', () => { + it('should contain valid data', () => { + const Parent = (_: {||}) => { + Scheduler.unstable_advanceTime(10); + return ( + + + + + + ); + }; + + // Memoize children to verify that chart doesn't include in the update. + const Child = React.memo(function Child({ duration }) { + Scheduler.unstable_advanceTime(duration); + return null; + }); + + const container = document.createElement('div'); + + utils.act(() => store.profilerStore.startProfiling()); + utils.act(() => + SchedulerTracing.unstable_trace('mount', Scheduler.unstable_now(), () => + ReactDOM.render(, container) + ) + ); + utils.act(() => + SchedulerTracing.unstable_trace( + 'update', + Scheduler.unstable_now(), + () => ReactDOM.render(, container) + ) + ); + utils.act(() => store.profilerStore.stopProfiling()); + + let renderFinished = false; + + function Validator({ commitIndex, rootID }) { + const commitTree = store.profilerStore.profilingCache.getCommitTree({ + commitIndex, + rootID, + }); + const chartData = store.profilerStore.profilingCache.getRankedChartData( + { + commitIndex, + commitTree, + rootID, + } + ); + expect(commitTree).toMatchSnapshot(`${commitIndex}: CommitTree`); + expect(chartData).toMatchSnapshot(`${commitIndex}: RankedChartData`); + renderFinished = true; + return null; + } + + const rootID = store.roots[0]; + + for (let commitIndex = 0; commitIndex < 2; commitIndex++) { + renderFinished = false; + + utils.act(() => { + TestRenderer.create( + + ); + }); + + expect(renderFinished).toBe(true); + } + }); + }); + + describe('interactions', () => { + it('should contain valid data', () => { + const Parent = (_: {||}) => { + Scheduler.unstable_advanceTime(10); + return ( + + + + + + ); + }; + + // Memoize children to verify that chart doesn't include in the update. + const Child = React.memo(function Child({ duration }) { + Scheduler.unstable_advanceTime(duration); + return null; + }); + + const container = document.createElement('div'); + + utils.act(() => store.profilerStore.startProfiling()); + utils.act(() => + SchedulerTracing.unstable_trace('mount', Scheduler.unstable_now(), () => + ReactDOM.render(, container) + ) + ); + utils.act(() => + SchedulerTracing.unstable_trace( + 'update', + Scheduler.unstable_now(), + () => ReactDOM.render(, container) + ) + ); + utils.act(() => store.profilerStore.stopProfiling()); + + let renderFinished = false; + + function Validator({ commitIndex, rootID }) { + const chartData = store.profilerStore.profilingCache.getInteractionsChartData( + { + rootID, + } + ); + expect(chartData).toMatchSnapshot('Interactions'); + renderFinished = true; + return null; + } + + const rootID = store.roots[0]; + + for (let commitIndex = 0; commitIndex < 2; commitIndex++) { + renderFinished = false; + + utils.act(() => { + TestRenderer.create( + + ); + }); + + expect(renderFinished).toBe(true); + } + }); + }); +}); diff --git a/extension/src/__tests__/profilingCommitTreeBuilder-test.js b/extension/src/__tests__/profilingCommitTreeBuilder-test.js new file mode 100644 index 0000000000000..0ef670607f967 --- /dev/null +++ b/extension/src/__tests__/profilingCommitTreeBuilder-test.js @@ -0,0 +1,75 @@ +// @flow + +import typeof TestRendererType from 'react-test-renderer'; +import type Store from 'src/devtools/store'; + +describe('commit tree', () => { + let React; + let ReactDOM; + let Scheduler; + let TestRenderer: TestRendererType; + let store: Store; + let utils; + + beforeEach(() => { + utils = require('./utils'); + utils.beforeEachProfiling(); + + store = global.store; + store.collapseNodesByDefault = false; + store.recordChangeDescriptions = true; + + React = require('react'); + ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); + TestRenderer = utils.requireTestRenderer(); + }); + + it('should be able to rebuild the store tree for each commit', () => { + const Parent = ({ count }) => { + Scheduler.unstable_advanceTime(10); + return new Array(count) + .fill(true) + .map((_, index) => ); + }; + const Child = React.memo(function Child() { + Scheduler.unstable_advanceTime(2); + return null; + }); + + const container = document.createElement('div'); + + utils.act(() => store.profilerStore.startProfiling()); + utils.act(() => ReactDOM.render(, container)); + utils.act(() => ReactDOM.render(, container)); + utils.act(() => ReactDOM.render(, container)); + utils.act(() => ReactDOM.render(, container)); + utils.act(() => store.profilerStore.stopProfiling()); + + let renderFinished = false; + + function Validator({ commitIndex, rootID }) { + const commitTree = store.profilerStore.profilingCache.getCommitTree({ + commitIndex, + rootID, + }); + expect(commitTree).toMatchSnapshot(`${commitIndex}: CommitTree`); + renderFinished = true; + return null; + } + + const rootID = store.roots[0]; + + for (let commitIndex = 0; commitIndex < 4; commitIndex++) { + renderFinished = false; + + utils.act(() => { + TestRenderer.create( + + ); + }); + + expect(renderFinished).toBe(true); + } + }); +}); diff --git a/extension/src/__tests__/profilingUtils-test.js b/extension/src/__tests__/profilingUtils-test.js new file mode 100644 index 0000000000000..66029a2568707 --- /dev/null +++ b/extension/src/__tests__/profilingUtils-test.js @@ -0,0 +1,20 @@ +// @flow + +describe('profiling utils', () => { + let utils; + + beforeEach(() => { + utils = require('src/devtools/views/Profiler/utils'); + }); + + it('should throw if importing older/unsupported data', () => { + expect(() => + utils.prepareProfilingDataFrontendFromExport( + ({ + version: 0, + dataForRoots: [], + }: any) + ) + ).toThrow('Unsupported profiler export version "0"'); + }); +}); diff --git a/extension/src/__tests__/setupEnv.js b/extension/src/__tests__/setupEnv.js new file mode 100644 index 0000000000000..0198427ad94ef --- /dev/null +++ b/extension/src/__tests__/setupEnv.js @@ -0,0 +1,15 @@ +// @flow + +import storage from 'local-storage-fallback'; + +// In case async/await syntax is used in a test. +import 'regenerator-runtime/runtime'; + +// DevTools stores preferences between sessions in localStorage +if (!global.hasOwnProperty('localStorage')) { + global.localStorage = storage; +} + +// Mimic the global we set with Webpack's DefinePlugin +global.__DEV__ = process.env.NODE_ENV !== 'production'; +global.__TEST__ = process.env.NODE_ENV === 'test'; diff --git a/extension/src/__tests__/setupTests.js b/extension/src/__tests__/setupTests.js new file mode 100644 index 0000000000000..3fb0a57a2f279 --- /dev/null +++ b/extension/src/__tests__/setupTests.js @@ -0,0 +1,76 @@ +// @flow + +import type { BackendBridge, FrontendBridge } from 'src/bridge'; + +const env = jasmine.getEnv(); +env.beforeEach(() => { + // These files should be required (and re-reuired) before each test, + // rather than imported at the head of the module. + // That's because we reset modules between tests, + // which disconnects the DevTool's cache from the current dispatcher ref. + const Agent = require('src/backend/agent').default; + const { initBackend } = require('src/backend'); + const Bridge = require('src/bridge').default; + const Store = require('src/devtools/store').default; + const { installHook } = require('src/hook'); + const { + getDefaultComponentFilters, + saveComponentFilters, + } = require('src/utils'); + + // Fake timers let us flush Bridge operations between setup and assertions. + jest.useFakeTimers(); + + const originalConsoleError = console.error; + // $FlowFixMe + console.error = (...args) => { + if (args[0] === 'Warning: React DevTools encountered an error: %s') { + // Rethrow errors from React. + throw args[1]; + } + originalConsoleError.apply(console, args); + }; + + // Initialize filters to a known good state. + saveComponentFilters(getDefaultComponentFilters()); + global.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = getDefaultComponentFilters(); + + installHook(global); + + const bridgeListeners = []; + const bridge = new Bridge({ + listen(callback) { + bridgeListeners.push(callback); + return () => { + const index = bridgeListeners.indexOf(callback); + if (index >= 0) { + bridgeListeners.splice(index, 1); + } + }; + }, + send(event: string, payload: any, transferable?: Array) { + bridgeListeners.forEach(callback => callback({ event, payload })); + }, + }); + + const agent = new Agent(((bridge: any): BackendBridge)); + + const hook = global.__REACT_DEVTOOLS_GLOBAL_HOOK__; + + initBackend(hook, agent, global); + + const store = new Store(((bridge: any): FrontendBridge)); + + global.agent = agent; + global.bridge = bridge; + global.store = store; +}); +env.afterEach(() => { + delete global.__REACT_DEVTOOLS_GLOBAL_HOOK__; + + // It's important to reset modules between test runs; + // Without this, ReactDOM won't re-inject itself into the new hook. + // It's also important to reset after tests, rather than before, + // so that we don't disconnect the ReactCurrentDispatcher ref. + jest.resetModules(); +}); diff --git a/extension/src/__tests__/store-test.js b/extension/src/__tests__/store-test.js new file mode 100644 index 0000000000000..7c273a7c3158f --- /dev/null +++ b/extension/src/__tests__/store-test.js @@ -0,0 +1,800 @@ +// @flow + +describe('Store', () => { + let React; + let ReactDOM; + let agent; + let act; + let getRendererID; + let store; + + beforeEach(() => { + agent = global.agent; + store = global.store; + + React = require('react'); + ReactDOM = require('react-dom'); + + const utils = require('./utils'); + act = utils.act; + getRendererID = utils.getRendererID; + }); + + it('should not allow a root node to be collapsed', () => { + const Component = () =>
Hi
; + + act(() => + ReactDOM.render(, document.createElement('div')) + ); + expect(store).toMatchSnapshot('1: mount'); + + expect(store.roots).toHaveLength(1); + + const rootID = store.roots[0]; + + expect(() => store.toggleIsCollapsed(rootID, true)).toThrow( + 'Root nodes cannot be collapsed' + ); + }); + + it('should properly handle a root with no visible nodes', () => { + const Root = ({ children }) => children; + + const container = document.createElement('div'); + + act(() => ReactDOM.render({null}, container)); + expect(store).toMatchSnapshot('1: mount'); + + act(() => ReactDOM.render(
, container)); + expect(store).toMatchSnapshot('2: add host nodes'); + }); + + describe('collapseNodesByDefault:false', () => { + beforeEach(() => { + store.collapseNodesByDefault = false; + }); + + it('should support mount and update operations', () => { + const Grandparent = ({ count }) => ( + + + + + ); + const Parent = ({ count }) => + new Array(count).fill(true).map((_, index) => ); + const Child = () =>
Hi!
; + + const container = document.createElement('div'); + + act(() => ReactDOM.render(, container)); + expect(store).toMatchSnapshot('1: mount'); + + act(() => ReactDOM.render(, container)); + expect(store).toMatchSnapshot('2: update'); + + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(store).toMatchSnapshot('3: unmount'); + }); + + it('should support mount and update operations for multiple roots', () => { + const Parent = ({ count }) => + new Array(count).fill(true).map((_, index) => ); + const Child = () =>
Hi!
; + + const containerA = document.createElement('div'); + const containerB = document.createElement('div'); + + act(() => { + ReactDOM.render(, containerA); + ReactDOM.render(, containerB); + }); + expect(store).toMatchSnapshot('1: mount'); + + act(() => { + ReactDOM.render(, containerA); + ReactDOM.render(, containerB); + }); + expect(store).toMatchSnapshot('2: update'); + + act(() => ReactDOM.unmountComponentAtNode(containerB)); + expect(store).toMatchSnapshot('3: unmount B'); + + act(() => ReactDOM.unmountComponentAtNode(containerA)); + expect(store).toMatchSnapshot('4: unmount A'); + }); + + it('should filter DOM nodes from the store tree', () => { + const Grandparent = () => ( +
+
+ +
+ +
+ ); + const Parent = () => ( +
+ +
+ ); + const Child = () =>
Hi!
; + + act(() => + ReactDOM.render( + , + document.createElement('div') + ) + ); + expect(store).toMatchSnapshot('1: mount'); + }); + + it('should display Suspense nodes properly in various states', () => { + const Loading = () =>
Loading...
; + const SuspendingComponent = () => { + throw new Promise(() => {}); + }; + const Component = () => { + return
Hello
; + }; + const Wrapper = ({ shouldSuspense }) => ( + + + }> + {shouldSuspense ? ( + + ) : ( + + )} + + + ); + + const container = document.createElement('div'); + act(() => ReactDOM.render(, container)); + expect(store).toMatchSnapshot('1: loading'); + + act(() => { + ReactDOM.render(, container); + }); + expect(store).toMatchSnapshot('2: resolved'); + }); + + it('should support nested Suspense nodes', () => { + const Component = () => null; + const Loading = () =>
Loading...
; + const Never = () => { + throw new Promise(() => {}); + }; + + const Wrapper = ({ + suspendFirst = false, + suspendSecond = false, + suspendParent = false, + }) => ( + + + }> + + }> + {suspendFirst ? ( + + ) : ( + + )} + + }> + {suspendSecond ? ( + + ) : ( + + )} + + }> + + + {suspendParent && } + + + + ); + + const container = document.createElement('div'); + act(() => + ReactDOM.render( + , + container + ) + ); + expect(store).toMatchSnapshot('1: third child is suspended'); + act(() => + ReactDOM.render( + , + container + ) + ); + expect(store).toMatchSnapshot('2: first and third child are suspended'); + act(() => + ReactDOM.render( + , + container + ) + ); + expect(store).toMatchSnapshot('3: second and third child are suspended'); + act(() => + ReactDOM.render( + , + container + ) + ); + expect(store).toMatchSnapshot('4: first and third child are suspended'); + act(() => + ReactDOM.render( + , + container + ) + ); + expect(store).toMatchSnapshot('5: parent is suspended'); + act(() => + ReactDOM.render( + , + container + ) + ); + expect(store).toMatchSnapshot('6: all children are suspended'); + act(() => + ReactDOM.render( + , + container + ) + ); + expect(store).toMatchSnapshot('7: only third child is suspended'); + + const rendererID = getRendererID(); + act(() => + agent.overrideSuspense({ + id: store.getElementIDAtIndex(4), + rendererID, + forceFallback: true, + }) + ); + expect(store).toMatchSnapshot('8: first and third child are suspended'); + act(() => + agent.overrideSuspense({ + id: store.getElementIDAtIndex(2), + rendererID, + forceFallback: true, + }) + ); + expect(store).toMatchSnapshot('9: parent is suspended'); + act(() => + ReactDOM.render( + , + container + ) + ); + expect(store).toMatchSnapshot('10: parent is suspended'); + act(() => + agent.overrideSuspense({ + id: store.getElementIDAtIndex(2), + rendererID, + forceFallback: false, + }) + ); + expect(store).toMatchSnapshot('11: all children are suspended'); + act(() => + agent.overrideSuspense({ + id: store.getElementIDAtIndex(4), + rendererID, + forceFallback: false, + }) + ); + expect(store).toMatchSnapshot('12: all children are suspended'); + act(() => + ReactDOM.render( + , + container + ) + ); + expect(store).toMatchSnapshot('13: third child is suspended'); + }); + + it('should support collapsing parts of the tree', () => { + const Grandparent = ({ count }) => ( + + + + + ); + const Parent = ({ count }) => + new Array(count).fill(true).map((_, index) => ); + const Child = () =>
Hi!
; + + act(() => + ReactDOM.render( + , + document.createElement('div') + ) + ); + expect(store).toMatchSnapshot('1: mount'); + + const grandparentID = store.getElementIDAtIndex(0); + const parentOneID = store.getElementIDAtIndex(1); + const parentTwoID = store.getElementIDAtIndex(4); + + act(() => store.toggleIsCollapsed(parentOneID, true)); + expect(store).toMatchSnapshot('2: collapse first Parent'); + + act(() => store.toggleIsCollapsed(parentTwoID, true)); + expect(store).toMatchSnapshot('3: collapse second Parent'); + + act(() => store.toggleIsCollapsed(parentOneID, false)); + expect(store).toMatchSnapshot('4: expand first Parent'); + + act(() => store.toggleIsCollapsed(grandparentID, true)); + expect(store).toMatchSnapshot('5: collapse Grandparent'); + + act(() => store.toggleIsCollapsed(grandparentID, false)); + expect(store).toMatchSnapshot('6: expand Grandparent'); + }); + + it('should support reordering of children', () => { + const Root = ({ children }) => children; + const Component = () => null; + + const Foo = () => []; + const Bar = () => [, ]; + const foo = ; + const bar = ; + + const container = document.createElement('div'); + + act(() => ReactDOM.render({[foo, bar]}, container)); + expect(store).toMatchSnapshot('1: mount'); + + act(() => ReactDOM.render({[bar, foo]}, container)); + expect(store).toMatchSnapshot('3: reorder children'); + + act(() => store.toggleIsCollapsed(store.getElementIDAtIndex(0), true)); + expect(store).toMatchSnapshot('4: collapse root'); + + act(() => store.toggleIsCollapsed(store.getElementIDAtIndex(0), false)); + expect(store).toMatchSnapshot('5: expand root'); + }); + }); + + describe('collapseNodesByDefault:true', () => { + beforeEach(() => { + store.collapseNodesByDefault = true; + }); + + it('should support mount and update operations', () => { + const Parent = ({ count }) => + new Array(count).fill(true).map((_, index) => ); + const Child = () =>
Hi!
; + + const container = document.createElement('div'); + + act(() => + ReactDOM.render( + + + + , + container + ) + ); + expect(store).toMatchSnapshot('1: mount'); + + act(() => + ReactDOM.render( + + + + , + container + ) + ); + expect(store).toMatchSnapshot('2: update'); + + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(store).toMatchSnapshot('3: unmount'); + }); + + it('should support mount and update operations for multiple roots', () => { + const Parent = ({ count }) => + new Array(count).fill(true).map((_, index) => ); + const Child = () =>
Hi!
; + + const containerA = document.createElement('div'); + const containerB = document.createElement('div'); + + act(() => { + ReactDOM.render(, containerA); + ReactDOM.render(, containerB); + }); + expect(store).toMatchSnapshot('1: mount'); + + act(() => { + ReactDOM.render(, containerA); + ReactDOM.render(, containerB); + }); + expect(store).toMatchSnapshot('2: update'); + + act(() => ReactDOM.unmountComponentAtNode(containerB)); + expect(store).toMatchSnapshot('3: unmount B'); + + act(() => ReactDOM.unmountComponentAtNode(containerA)); + expect(store).toMatchSnapshot('4: unmount A'); + }); + + it('should filter DOM nodes from the store tree', () => { + const Grandparent = () => ( +
+
+ +
+ +
+ ); + const Parent = () => ( +
+ +
+ ); + const Child = () =>
Hi!
; + + act(() => + ReactDOM.render( + , + document.createElement('div') + ) + ); + expect(store).toMatchSnapshot('1: mount'); + + act(() => store.toggleIsCollapsed(store.getElementIDAtIndex(0), false)); + expect(store).toMatchSnapshot('2: expand Grandparent'); + + act(() => store.toggleIsCollapsed(store.getElementIDAtIndex(1), false)); + expect(store).toMatchSnapshot('3: expand Parent'); + }); + + it('should display Suspense nodes properly in various states', () => { + const Loading = () =>
Loading...
; + const SuspendingComponent = () => { + throw new Promise(() => {}); + }; + const Component = () => { + return
Hello
; + }; + const Wrapper = ({ shouldSuspense }) => ( + + + }> + {shouldSuspense ? ( + + ) : ( + + )} + + + ); + + const container = document.createElement('div'); + act(() => ReactDOM.render(, container)); + expect(store).toMatchSnapshot('1: loading'); + + // This test isn't meaningful unless we expand the suspended tree + act(() => store.toggleIsCollapsed(store.getElementIDAtIndex(0), false)); + act(() => store.toggleIsCollapsed(store.getElementIDAtIndex(2), false)); + expect(store).toMatchSnapshot('2: expand Wrapper and Suspense'); + + act(() => { + ReactDOM.render(, container); + }); + expect(store).toMatchSnapshot('2: resolved'); + }); + + it('should support expanding parts of the tree', () => { + const Grandparent = ({ count }) => ( + + + + + ); + const Parent = ({ count }) => + new Array(count).fill(true).map((_, index) => ); + const Child = () =>
Hi!
; + + act(() => + ReactDOM.render( + , + document.createElement('div') + ) + ); + expect(store).toMatchSnapshot('1: mount'); + + const grandparentID = store.getElementIDAtIndex(0); + + act(() => store.toggleIsCollapsed(grandparentID, false)); + expect(store).toMatchSnapshot('2: expand Grandparent'); + + const parentOneID = store.getElementIDAtIndex(1); + const parentTwoID = store.getElementIDAtIndex(2); + + act(() => store.toggleIsCollapsed(parentOneID, false)); + expect(store).toMatchSnapshot('3: expand first Parent'); + + act(() => store.toggleIsCollapsed(parentTwoID, false)); + expect(store).toMatchSnapshot('4: expand second Parent'); + + act(() => store.toggleIsCollapsed(parentOneID, true)); + expect(store).toMatchSnapshot('5: collapse first Parent'); + + act(() => store.toggleIsCollapsed(parentTwoID, true)); + expect(store).toMatchSnapshot('6: collapse second Parent'); + + act(() => store.toggleIsCollapsed(grandparentID, true)); + expect(store).toMatchSnapshot('7: collapse Grandparent'); + }); + + it('should support expanding deep parts of the tree', () => { + const Wrapper = ({ forwardedRef }) => ( + + ); + const Nested = ({ depth, forwardedRef }) => + depth > 0 ? ( + + ) : ( +
+ ); + + const ref = React.createRef(); + + act(() => + ReactDOM.render( + , + document.createElement('div') + ) + ); + expect(store).toMatchSnapshot('1: mount'); + + const deepestedNodeID = agent.getIDForNode(ref.current); + + act(() => store.toggleIsCollapsed(deepestedNodeID, false)); + expect(store).toMatchSnapshot('2: expand deepest node'); + + const rootID = store.getElementIDAtIndex(0); + + act(() => store.toggleIsCollapsed(rootID, true)); + expect(store).toMatchSnapshot('3: collapse root'); + + act(() => store.toggleIsCollapsed(rootID, false)); + expect(store).toMatchSnapshot('4: expand root'); + + const id = store.getElementIDAtIndex(1); + + act(() => store.toggleIsCollapsed(id, true)); + expect(store).toMatchSnapshot('5: collapse middle node'); + + act(() => store.toggleIsCollapsed(id, false)); + expect(store).toMatchSnapshot('6: expand middle node'); + }); + + it('should support reordering of children', () => { + const Root = ({ children }) => children; + const Component = () => null; + + const Foo = () => []; + const Bar = () => [, ]; + const foo = ; + const bar = ; + + const container = document.createElement('div'); + + act(() => ReactDOM.render({[foo, bar]}, container)); + expect(store).toMatchSnapshot('1: mount'); + + act(() => ReactDOM.render({[bar, foo]}, container)); + expect(store).toMatchSnapshot('3: reorder children'); + + act(() => store.toggleIsCollapsed(store.getElementIDAtIndex(0), false)); + expect(store).toMatchSnapshot('4: expand root'); + + act(() => { + store.toggleIsCollapsed(store.getElementIDAtIndex(2), false); + store.toggleIsCollapsed(store.getElementIDAtIndex(1), false); + }); + expect(store).toMatchSnapshot('5: expand leaves'); + + act(() => store.toggleIsCollapsed(store.getElementIDAtIndex(0), true)); + expect(store).toMatchSnapshot('6: collapse root'); + }); + + it('should not add new nodes when suspense is toggled', () => { + const SuspenseTree = () => { + return ( + Loading outer}> + + + ); + }; + + const Fallback = () => null; + const Parent = () => ; + const Child = () => null; + + act(() => + ReactDOM.render(, document.createElement('div')) + ); + expect(store).toMatchSnapshot('1: mount'); + + act(() => store.toggleIsCollapsed(store.getElementIDAtIndex(0), false)); + act(() => store.toggleIsCollapsed(store.getElementIDAtIndex(1), false)); + expect(store).toMatchSnapshot('2: expand tree'); + + const rendererID = getRendererID(); + const suspenseID = store.getElementIDAtIndex(1); + + act(() => + agent.overrideSuspense({ + id: suspenseID, + rendererID, + forceFallback: true, + }) + ); + expect(store).toMatchSnapshot('3: toggle fallback on'); + + act(() => + agent.overrideSuspense({ + id: suspenseID, + rendererID, + forceFallback: false, + }) + ); + expect(store).toMatchSnapshot('4: toggle fallback on'); + }); + }); + + describe('getIndexOfElementID', () => { + beforeEach(() => { + store.collapseNodesByDefault = false; + }); + + it('should support a single root with a single child', () => { + const Grandparent = () => ( + + + + + ); + const Parent = () => ; + const Child = () => null; + + act(() => + ReactDOM.render(, document.createElement('div')) + ); + + for (let i = 0; i < store.numElements; i++) { + expect(store.getIndexOfElementID(store.getElementIDAtIndex(i))).toBe(i); + } + }); + + it('should support multiple roots with one children each', () => { + const Grandparent = () => ; + const Parent = () => ; + const Child = () => null; + + act(() => { + ReactDOM.render(, document.createElement('div')); + ReactDOM.render(, document.createElement('div')); + }); + + for (let i = 0; i < store.numElements; i++) { + expect(store.getIndexOfElementID(store.getElementIDAtIndex(i))).toBe(i); + } + }); + + it('should support a single root with multiple top level children', () => { + const Grandparent = () => ; + const Parent = () => ; + const Child = () => null; + + act(() => + ReactDOM.render( + + + + , + document.createElement('div') + ) + ); + + for (let i = 0; i < store.numElements; i++) { + expect(store.getIndexOfElementID(store.getElementIDAtIndex(i))).toBe(i); + } + }); + + it('should support multiple roots with multiple top level children', () => { + const Grandparent = () => ; + const Parent = () => ; + const Child = () => null; + + act(() => { + ReactDOM.render( + + + + , + document.createElement('div') + ); + ReactDOM.render( + + + + , + document.createElement('div') + ); + }); + + for (let i = 0; i < store.numElements; i++) { + expect(store.getIndexOfElementID(store.getElementIDAtIndex(i))).toBe(i); + } + }); + }); + + it('detects and updates profiling support based on the attached roots', () => { + const Component = () => null; + + const containerA = document.createElement('div'); + const containerB = document.createElement('div'); + + expect(store.supportsProfiling).toBe(false); + + act(() => ReactDOM.render(, containerA)); + expect(store.supportsProfiling).toBe(true); + + act(() => ReactDOM.render(, containerB)); + act(() => ReactDOM.unmountComponentAtNode(containerA)); + expect(store.supportsProfiling).toBe(true); + + act(() => ReactDOM.unmountComponentAtNode(containerB)); + expect(store.supportsProfiling).toBe(false); + }); +}); diff --git a/extension/src/__tests__/storeComponentFilters-test.js b/extension/src/__tests__/storeComponentFilters-test.js new file mode 100644 index 0000000000000..5e2385b33b96b --- /dev/null +++ b/extension/src/__tests__/storeComponentFilters-test.js @@ -0,0 +1,223 @@ +// @flow + +import type { FrontendBridge } from 'src/bridge'; +import type Store from 'src/devtools/store'; + +describe('Store component filters', () => { + let React; + let ReactDOM; + let TestUtils; + let Types; + let bridge: FrontendBridge; + let store: Store; + let utils; + + const act = (callback: Function) => { + TestUtils.act(() => { + callback(); + }); + jest.runAllTimers(); // Flush Bridge operations + }; + + beforeEach(() => { + bridge = global.bridge; + store = global.store; + store.collapseNodesByDefault = false; + store.componentFilters = []; + store.recordChangeDescriptions = true; + + React = require('react'); + ReactDOM = require('react-dom'); + TestUtils = require('react-dom/test-utils'); + Types = require('src/types'); + utils = require('./utils'); + }); + + it('should throw if filters are updated while profiling', () => { + act(() => store.profilerStore.startProfiling()); + expect(() => (store.componentFilters = [])).toThrow( + 'Cannot modify filter preferences while profiling' + ); + }); + + it('should support filtering by element type', () => { + class Root extends React.Component<{| children: React$Node |}> { + render() { + return
{this.props.children}
; + } + } + const Component = () =>
Hi
; + + act(() => + ReactDOM.render( + + + , + document.createElement('div') + ) + ); + expect(store).toMatchSnapshot('1: mount'); + + act( + () => + (store.componentFilters = [ + utils.createElementTypeFilter(Types.ElementTypeHostComponent), + ]) + ); + + expect(store).toMatchSnapshot('2: hide host components'); + + act( + () => + (store.componentFilters = [ + utils.createElementTypeFilter(Types.ElementTypeClass), + ]) + ); + + expect(store).toMatchSnapshot('3: hide class components'); + + act( + () => + (store.componentFilters = [ + utils.createElementTypeFilter(Types.ElementTypeClass), + utils.createElementTypeFilter(Types.ElementTypeFunction), + ]) + ); + + expect(store).toMatchSnapshot('4: hide class and function components'); + + act( + () => + (store.componentFilters = [ + utils.createElementTypeFilter(Types.ElementTypeClass, false), + utils.createElementTypeFilter(Types.ElementTypeFunction, false), + ]) + ); + + expect(store).toMatchSnapshot('5: disable all filters'); + }); + + it('should ignore invalid ElementTypeRoot filter', () => { + const Root = () =>
Hi
; + + act(() => ReactDOM.render(, document.createElement('div'))); + expect(store).toMatchSnapshot('1: mount'); + + act( + () => + (store.componentFilters = [ + utils.createElementTypeFilter(Types.ElementTypeRoot), + ]) + ); + + expect(store).toMatchSnapshot('2: add invalid filter'); + }); + + it('should filter by display name', () => { + const Text = ({ label }) => label; + const Foo = () => ; + const Bar = () => ; + const Baz = () => ; + + act(() => + ReactDOM.render( + + + + + , + document.createElement('div') + ) + ); + expect(store).toMatchSnapshot('1: mount'); + + act( + () => (store.componentFilters = [utils.createDisplayNameFilter('Foo')]) + ); + expect(store).toMatchSnapshot('2: filter "Foo"'); + + act(() => (store.componentFilters = [utils.createDisplayNameFilter('Ba')])); + expect(store).toMatchSnapshot('3: filter "Ba"'); + + act( + () => (store.componentFilters = [utils.createDisplayNameFilter('B.z')]) + ); + expect(store).toMatchSnapshot('4: filter "B.z"'); + }); + + it('should filter by path', () => { + const Component = () =>
Hi
; + + act(() => ReactDOM.render(, document.createElement('div'))); + expect(store).toMatchSnapshot('1: mount'); + + act( + () => + (store.componentFilters = [ + utils.createLocationFilter(__filename.replace(__dirname, '')), + ]) + ); + + expect(store).toMatchSnapshot( + '2: hide all components declared within this test filed' + ); + + act( + () => + (store.componentFilters = [ + utils.createLocationFilter('this:is:a:made:up:path'), + ]) + ); + + expect(store).toMatchSnapshot('3: hide components in a made up fake path'); + }); + + it('should filter HOCs', () => { + const Component = () =>
Hi
; + const Foo = () => ; + Foo.displayName = 'Foo(Component)'; + const Bar = () => ; + Bar.displayName = 'Bar(Foo(Component))'; + + act(() => ReactDOM.render(, document.createElement('div'))); + expect(store).toMatchSnapshot('1: mount'); + + act(() => (store.componentFilters = [utils.createHOCFilter(true)])); + + expect(store).toMatchSnapshot('2: hide all HOCs'); + + act(() => (store.componentFilters = [utils.createHOCFilter(false)])); + + expect(store).toMatchSnapshot('3: disable HOC filter'); + }); + + it('should not send a bridge update if the set of enabled filters has not changed', () => { + act(() => (store.componentFilters = [utils.createHOCFilter(true)])); + + bridge.addListener('updateComponentFilters', componentFilters => { + throw Error('Unexpected component update'); + }); + + act( + () => + (store.componentFilters = [ + utils.createHOCFilter(false), + utils.createHOCFilter(true), + ]) + ); + act( + () => + (store.componentFilters = [ + utils.createHOCFilter(true), + utils.createLocationFilter('abc', false), + ]) + ); + act( + () => + (store.componentFilters = [ + utils.createHOCFilter(true), + utils.createElementTypeFilter(Types.ElementTypeHostComponent, false), + ]) + ); + }); +}); diff --git a/extension/src/__tests__/storeOwners-test.js b/extension/src/__tests__/storeOwners-test.js new file mode 100644 index 0000000000000..9ed0e53fdcc04 --- /dev/null +++ b/extension/src/__tests__/storeOwners-test.js @@ -0,0 +1,164 @@ +// @flow + +const { printOwnersList } = require('./storeSerializer'); + +describe('Store owners list', () => { + let React; + let ReactDOM; + let act; + let store; + + beforeEach(() => { + store = global.store; + store.collapseNodesByDefault = false; + + React = require('react'); + ReactDOM = require('react-dom'); + act = require('./utils').act; + }); + + it('should drill through intermediate components', () => { + const Root = () => ( + +
+ +
+
+ ); + const Wrapper = ({ children }) => children; + const Leaf = () =>
Leaf
; + const Intermediate = ({ children }) => {children}; + + act(() => ReactDOM.render(, document.createElement('div'))); + expect(store).toMatchSnapshot('1: mount'); + + const rootID = store.getElementIDAtIndex(0); + expect( + printOwnersList(store.getOwnersListForElement(rootID)) + ).toMatchSnapshot('2: components owned by '); + + const intermediateID = store.getElementIDAtIndex(1); + expect( + printOwnersList(store.getOwnersListForElement(intermediateID)) + ).toMatchSnapshot('3: components owned by '); + }); + + it('should drill through interleaved intermediate components', () => { + const Root = () => [ + + + , + , + ]; + const Wrapper = ({ children }) => children; + const Leaf = () =>
Leaf
; + const Intermediate = ({ children }) => [ + , + {children}, + ]; + + act(() => ReactDOM.render(, document.createElement('div'))); + expect(store).toMatchSnapshot('1: mount'); + + const rootID = store.getElementIDAtIndex(0); + expect( + printOwnersList(store.getOwnersListForElement(rootID)) + ).toMatchSnapshot('2: components owned by '); + + const intermediateID = store.getElementIDAtIndex(1); + expect( + printOwnersList(store.getOwnersListForElement(intermediateID)) + ).toMatchSnapshot('3: components owned by '); + }); + + it('should show the proper owners list order and contents after insertions and deletions', () => { + const Root = ({ includeDirect, includeIndirect }) => ( +
+ {includeDirect ? : null} + {includeIndirect ? ( + + + + ) : null} +
+ ); + const Wrapper = ({ children }) => children; + const Leaf = () =>
Leaf
; + const Intermediate = ({ children }) => {children}; + + const container = document.createElement('div'); + + act(() => + ReactDOM.render( + , + container + ) + ); + expect(store).toMatchSnapshot('1: mount'); + + const rootID = store.getElementIDAtIndex(0); + expect( + printOwnersList(store.getOwnersListForElement(rootID)) + ).toMatchSnapshot('2: components owned by '); + + act(() => + ReactDOM.render( + , + container + ) + ); + expect(store).toMatchSnapshot('3: update to add direct'); + + expect( + printOwnersList(store.getOwnersListForElement(rootID)) + ).toMatchSnapshot('4: components owned by '); + + act(() => + ReactDOM.render( + , + container + ) + ); + expect(store).toMatchSnapshot('5: update to remove indirect'); + + expect( + printOwnersList(store.getOwnersListForElement(rootID)) + ).toMatchSnapshot('6: components owned by '); + + act(() => + ReactDOM.render( + , + container + ) + ); + expect(store).toMatchSnapshot('7: update to remove both'); + + expect( + printOwnersList(store.getOwnersListForElement(rootID)) + ).toMatchSnapshot('8: components owned by '); + }); + + it('should show the proper owners list ordering after reordered children', () => { + const Root = ({ ascending }) => + ascending + ? [, , ] + : [, , ]; + const Leaf = () =>
Leaf
; + + const container = document.createElement('div'); + act(() => ReactDOM.render(, container)); + expect(store).toMatchSnapshot('1: mount (ascending)'); + + const rootID = store.getElementIDAtIndex(0); + expect( + printOwnersList(store.getOwnersListForElement(rootID)) + ).toMatchSnapshot('2: components owned by '); + + act(() => ReactDOM.render(, container)); + expect(store).toMatchSnapshot('3: update (descending)'); + + expect( + printOwnersList(store.getOwnersListForElement(rootID)) + ).toMatchSnapshot('4: components owned by '); + }); +}); diff --git a/extension/src/__tests__/storeSerializer.js b/extension/src/__tests__/storeSerializer.js new file mode 100644 index 0000000000000..fd2d4e36f8ab3 --- /dev/null +++ b/extension/src/__tests__/storeSerializer.js @@ -0,0 +1,84 @@ +// test() is part of Jest's serializer API +export function test(maybeStore) { + // It's important to lazy-require the Store rather than imported at the head of the module. + // Because we reset modules between tests, different Store implementations will be used for each test. + // Unfortunately Jest does not reset its own serializer modules. + return maybeStore instanceof require('src/devtools/store').default; +} + +// print() is part of Jest's serializer API +export function print(store, serialize, indent) { + return printStore(store); +} + +export function printElement(element, includeWeight = false) { + let prefix = ' '; + if (element.children.length > 0) { + prefix = element.isCollapsed ? '▸' : '▾'; + } + + let key = ''; + if (element.key !== null) { + key = ` key="${element.key}"`; + } + + let hocs = ''; + if (element.hocDisplayNames !== null) { + hocs = ` [${element.hocDisplayNames.join('][')}]`; + } + + let suffix = ''; + if (includeWeight) { + suffix = ` (${element.isCollapsed ? 1 : element.weight})`; + } + + return `${' '.repeat(element.depth + 1)}${prefix} <${element.displayName || + 'null'}${key}>${hocs}${suffix}`; +} + +export function printOwnersList(elements, includeWeight = false) { + return elements + .map(element => printElement(element, includeWeight)) + .join('\n'); +} + +// Used for Jest snapshot testing. +// May also be useful for visually debugging the tree, so it lives on the Store. +export function printStore(store, includeWeight = false) { + const snapshotLines = []; + + let rootWeight = 0; + + store.roots.forEach(rootID => { + const { weight } = store.getElementByID(rootID); + + snapshotLines.push('[root]' + (includeWeight ? ` (${weight})` : '')); + + for (let i = rootWeight; i < rootWeight + weight; i++) { + const element = store.getElementAtIndex(i); + + if (element == null) { + throw Error(`Could not find element at index ${i}`); + } + + snapshotLines.push(printElement(element, includeWeight)); + } + + rootWeight += weight; + }); + + // Make sure the pretty-printed test align with the Store's reported number of total rows. + if (rootWeight !== store.numElements) { + throw Error( + `Inconsistent Store state. Individual root weights (${rootWeight}) do not match total weight (${ + store.numElements + })` + ); + } + + // If roots have been unmounted, verify that they've been removed from maps. + // This helps ensure the Store doesn't leak memory. + store.assertExpectedRootMapSizes(); + + return snapshotLines.join('\n'); +} diff --git a/extension/src/__tests__/storeStressSync-test.js b/extension/src/__tests__/storeStressSync-test.js new file mode 100644 index 0000000000000..8877d5d9992d2 --- /dev/null +++ b/extension/src/__tests__/storeStressSync-test.js @@ -0,0 +1,1095 @@ +// @flow + +describe('StoreStress (Sync Mode)', () => { + let React; + let ReactDOM; + let act; + let bridge; + let store; + let print; + + beforeEach(() => { + bridge = global.bridge; + store = global.store; + store.collapseNodesByDefault = false; + + React = require('react'); + ReactDOM = require('react-dom'); + act = require('./utils').act; + + print = require('./storeSerializer').print; + }); + + // This is a stress test for the tree mount/update/unmount traversal. + // It renders different trees that should produce the same output. + it('should handle a stress test with different tree operations (Sync Mode)', () => { + let setShowX; + const A = () => 'a'; + const B = () => 'b'; + const C = () => { + // We'll be manually flipping this component back and forth in the test. + // We only do this for a single node in order to verify that DevTools + // can handle a subtree switching alternates while other subtrees are memoized. + let [showX, _setShowX] = React.useState(false); + setShowX = _setShowX; + return showX ? : 'c'; + }; + const D = () => 'd'; + const E = () => 'e'; + const X = () => 'x'; + const a = ; + const b = ; + const c = ; + const d = ; + const e = ; + + function Parent({ children }) { + return children; + } + + // 1. Render a normal version of [a, b, c, d, e]. + let container = document.createElement('div'); + act(() => ReactDOM.render({[a, b, c, d, e]}, container)); + expect(store).toMatchSnapshot('1: abcde'); + expect(container.textContent).toMatch('abcde'); + const snapshotForABCDE = print(store); + + // 2. Render a version where renders an child instead of 'c'. + // This is how we'll test an update to a single component. + act(() => { + setShowX(true); + }); + expect(store).toMatchSnapshot('2: abxde'); + expect(container.textContent).toMatch('abxde'); + const snapshotForABXDE = print(store); + + // 3. Verify flipping it back produces the original result. + act(() => { + setShowX(false); + }); + expect(container.textContent).toMatch('abcde'); + expect(print(store)).toBe(snapshotForABCDE); + + // 4. Clean up. + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + + // Now comes the interesting part. + // All of these cases are equivalent to [a, b, c, d, e] in output. + // We'll verify that DevTools produces the same snapshots for them. + // These cases are picked so that rendering them sequentially in the same + // container results in a combination of mounts, updates, unmounts, and reorders. + // prettier-ignore + let cases = [ + [a, b, c, d, e], + [[a], b, c, d, e], + [[a, b], c, d, e], + [[a, b], c, [d, e]], + [[a, b], c, [d, '', e]], + [[a], b, c, d, [e]], + [a, b, [[c]], d, e], + [[a, ''], [b], [c], [d], [e]], + [a, b, [c, [d, ['', e]]]], + [a, b, c, d, e], + [
{a}
, b, c, d, e], + [
{a}{b}
, c, d, e], + [
{a}{b}
, c,
{d}{e}
], + [
{a}{b}
, c,
{d}{e}
], + [
{a}{b}
, c,
{d}{e}
], + [
{a}{b}
, c,
{d}{e}
], + [{a}, b, c, d, [e]], + [a, b, {c}, d, e], + [
{a}
, [b], {c}, [d],
{e}
], + [a, b, [c,
{d}{e}
], ''], + [a, [[]], b, c, [d, [[]], e]], + [[[a, b, c, d], e]], + [a, b, c, d, e] + ]; + + // 5. Test fresh mount for each case. + for (let i = 0; i < cases.length; i++) { + // Ensure fresh mount. + container = document.createElement('div'); + + // Verify mounting 'abcde'. + act(() => ReactDOM.render({cases[i]}, container)); + expect(container.textContent).toMatch('abcde'); + expect(print(store)).toEqual(snapshotForABCDE); + + // Verify switching to 'abxde'. + act(() => { + setShowX(true); + }); + expect(container.textContent).toMatch('abxde'); + expect(print(store)).toBe(snapshotForABXDE); + + // Verify switching back to 'abcde'. + act(() => { + setShowX(false); + }); + expect(container.textContent).toMatch('abcde'); + expect(print(store)).toBe(snapshotForABCDE); + + // Clean up. + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + + // 6. Verify *updates* by reusing the container between iterations. + // There'll be no unmounting until the very end. + container = document.createElement('div'); + for (let i = 0; i < cases.length; i++) { + // Verify mounting 'abcde'. + act(() => ReactDOM.render({cases[i]}, container)); + expect(container.textContent).toMatch('abcde'); + expect(print(store)).toEqual(snapshotForABCDE); + + // Verify switching to 'abxde'. + act(() => { + setShowX(true); + }); + expect(container.textContent).toMatch('abxde'); + expect(print(store)).toBe(snapshotForABXDE); + + // Verify switching back to 'abcde'. + act(() => { + setShowX(false); + }); + expect(container.textContent).toMatch('abcde'); + expect(print(store)).toBe(snapshotForABCDE); + // Don't unmount. Reuse the container between iterations. + } + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + }); + + it('should handle stress test with reordering (Sync Mode)', () => { + const A = () => 'a'; + const B = () => 'b'; + const C = () => 'c'; + const D = () => 'd'; + const E = () => 'e'; + const a =
; + const b = ; + const c = ; + const d = ; + const e = ; + + // prettier-ignore + let steps = [ + a, + b, + c, + d, + e, + [a], + [b], + [c], + [d], + [e], + [a, b], + [b, a], + [b, c], + [c, b], + [a, c], + [c, a], + ]; + + const Root = ({ children }) => { + return children; + }; + + // 1. Capture the expected render result. + let snapshots = []; + let container = document.createElement('div'); + for (let i = 0; i < steps.length; i++) { + act(() => ReactDOM.render({steps[i]}, container)); + // We snapshot each step once so it doesn't regress. + expect(store).toMatchSnapshot(); + snapshots.push(print(store)); + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + + // 2. Verify that we can update from every step to every other step and back. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + let container = document.createElement('div'); + act(() => ReactDOM.render({steps[i]}, container)); + expect(print(store)).toMatch(snapshots[i]); + act(() => ReactDOM.render({steps[j]}, container)); + expect(print(store)).toMatch(snapshots[j]); + act(() => ReactDOM.render({steps[i]}, container)); + expect(print(store)).toMatch(snapshots[i]); + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + } + + // 3. Same test as above, but this time we wrap children in a host component. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + let container = document.createElement('div'); + act(() => + ReactDOM.render( + +
{steps[i]}
+
, + container + ) + ); + expect(print(store)).toMatch(snapshots[i]); + act(() => + ReactDOM.render( + +
{steps[j]}
+
, + container + ) + ); + expect(print(store)).toMatch(snapshots[j]); + act(() => + ReactDOM.render( + +
{steps[i]}
+
, + container + ) + ); + expect(print(store)).toMatch(snapshots[i]); + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + } + }); + + it('should handle a stress test for Suspense (Sync Mode)', async () => { + const A = () => 'a'; + const B = () => 'b'; + const C = () => 'c'; + const X = () => 'x'; + const Y = () => 'y'; + const Z = () => 'z'; + const a =
; + const b = ; + const c = ; + const z = ; + + // prettier-ignore + const steps = [ + a, + [a], + [a, b, c], + [c, b, a], + [c, null, a], + {c}{a}, +
{c}{a}
, +
{a}{b}
, + [[a]], + null, + b, + a + ]; + + const Never = () => { + throw new Promise(() => {}); + }; + + const Root = ({ children }) => { + return children; + }; + + // 1. For each step, check Suspense can render them as initial primary content. + // This is the only step where we use Jest snapshots. + let snapshots = []; + let container = document.createElement('div'); + for (let i = 0; i < steps.length; i++) { + act(() => + ReactDOM.render( + + + {steps[i]} + + , + container + ) + ); + // We snapshot each step once so it doesn't regress. + expect(store).toMatchSnapshot(); + snapshots.push(print(store)); + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + + // 2. Verify check Suspense can render same steps as initial fallback content. + for (let i = 0; i < steps.length; i++) { + act(() => + ReactDOM.render( + + + + + + + + + , + container + ) + ); + expect(print(store)).toEqual(snapshots[i]); + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + + // 3. Verify we can update from each step to each step in primary mode. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + act(() => + ReactDOM.render( + + + {steps[i]} + + , + container + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Re-render with steps[j]. + act(() => + ReactDOM.render( + + + {steps[j]} + + , + container + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(snapshots[j]); + // Check that we can transition back again. + act(() => + ReactDOM.render( + + + {steps[i]} + + , + container + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Clean up after every iteration. + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + } + + // 4. Verify we can update from each step to each step in fallback mode. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + act(() => + ReactDOM.render( + + + + + + + + + , + container + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Re-render with steps[j]. + act(() => + ReactDOM.render( + + + + + + + + + , + container + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(snapshots[j]); + // Check that we can transition back again. + act(() => + ReactDOM.render( + + + + + + + + + , + container + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Clean up after every iteration. + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + } + + // 5. Verify we can update from each step to each step when moving primary -> fallback. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + act(() => + ReactDOM.render( + + + {steps[i]} + + , + container + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Re-render with steps[j]. + act(() => + ReactDOM.render( + + + + + + + + + , + container + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(snapshots[j]); + // Check that we can transition back again. + act(() => + ReactDOM.render( + + + {steps[i]} + + , + container + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Clean up after every iteration. + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + } + + // 6. Verify we can update from each step to each step when moving fallback -> primary. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + act(() => + ReactDOM.render( + + + + + + + + + , + container + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Re-render with steps[j]. + act(() => + ReactDOM.render( + + + {steps[j]} + + , + container + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(snapshots[j]); + // Check that we can transition back again. + act(() => + ReactDOM.render( + + + + + + + + + , + container + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Clean up after every iteration. + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + } + + // 7. Verify we can update from each step to each step when toggling Suspense. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + act(() => + ReactDOM.render( + + + {steps[i]} + + , + container + ) + ); + + // We get ID from the index in the tree above: + // Root, X, Suspense, ... + // ^ (index is 2) + const suspenseID = store.getElementIDAtIndex(2); + + // Force fallback. + expect(print(store)).toEqual(snapshots[i]); + act(() => { + const suspenseID = store.getElementIDAtIndex(2); + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: true, + }); + }); + expect(print(store)).toEqual(snapshots[j]); + + // Stop forcing fallback. + act(() => { + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: false, + }); + }); + expect(print(store)).toEqual(snapshots[i]); + + // Trigger actual fallback. + act(() => + ReactDOM.render( + + + + + + + + + , + container + ) + ); + expect(print(store)).toEqual(snapshots[j]); + + // Force fallback while we're in fallback mode. + act(() => { + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: true, + }); + }); + // Keep seeing fallback content. + expect(print(store)).toEqual(snapshots[j]); + + // Switch to primary mode. + act(() => + ReactDOM.render( + + + {steps[i]} + + , + container + ) + ); + // Fallback is still forced though. + expect(print(store)).toEqual(snapshots[j]); + + // Stop forcing fallback. This reverts to primary content. + act(() => { + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: false, + }); + }); + // Now we see primary content. + expect(print(store)).toEqual(snapshots[i]); + + // Clean up after every iteration. + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + } + }); + + it('should handle a stress test for Suspense without type change (Sync Mode)', () => { + const A = () => 'a'; + const B = () => 'b'; + const C = () => 'c'; + const X = () => 'x'; + const Y = () => 'y'; + const Z = () => 'z'; + const a =
; + const b = ; + const c = ; + const z = ; + + // prettier-ignore + const steps = [ + a, + [a], + [a, b, c], + [c, b, a], + [c, null, a], + {c}{a}, +
{c}{a}
, +
{a}{b}
, + [[a]], + null, + b, + a + ]; + + const Never = () => { + throw new Promise(() => {}); + }; + + const MaybeSuspend = ({ children, suspend }) => { + if (suspend) { + return ( +
+ {children} + + +
+ ); + } + return ( +
+ {children} + +
+ ); + }; + + const Root = ({ children }) => { + return children; + }; + + // 1. For each step, check Suspense can render them as initial primary content. + // This is the only step where we use Jest snapshots. + let snapshots = []; + let container = document.createElement('div'); + for (let i = 0; i < steps.length; i++) { + act(() => + ReactDOM.render( + + + + {steps[i]} + + + , + container + ) + ); + // We snapshot each step once so it doesn't regress. + expect(store).toMatchSnapshot(); + snapshots.push(print(store)); + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + + // 2. Verify check Suspense can render same steps as initial fallback content. + // We don't actually assert here because the tree includes + // which is different from the snapshots above. So we take more snapshots. + let fallbackSnapshots = []; + for (let i = 0; i < steps.length; i++) { + act(() => + ReactDOM.render( + + + + + {steps[i]} + + + + , + container + ) + ); + // We snapshot each step once so it doesn't regress. + expect(store).toMatchSnapshot(); + fallbackSnapshots.push(print(store)); + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + + // 3. Verify we can update from each step to each step in primary mode. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + act(() => + ReactDOM.render( + + + + {steps[i]} + + + , + container + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Re-render with steps[j]. + act(() => + ReactDOM.render( + + + + {steps[j]} + + + , + container + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(snapshots[j]); + // Check that we can transition back again. + act(() => + ReactDOM.render( + + + + {steps[i]} + + + , + container + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Clean up after every iteration. + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + } + + // 4. Verify we can update from each step to each step in fallback mode. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + act(() => + ReactDOM.render( + + + + + + + + + + + + , + container + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[i]); + // Re-render with steps[j]. + act(() => + ReactDOM.render( + + + + + + + + + + + + , + container + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(fallbackSnapshots[j]); + // Check that we can transition back again. + act(() => + ReactDOM.render( + + + + + + + + + + + + , + container + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[i]); + // Clean up after every iteration. + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + } + + // 5. Verify we can update from each step to each step when moving primary -> fallback. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + act(() => + ReactDOM.render( + + + + {steps[i]} + + + , + container + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Re-render with steps[j]. + act(() => + ReactDOM.render( + + + + {steps[i]} + + + , + container + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(fallbackSnapshots[j]); + // Check that we can transition back again. + act(() => + ReactDOM.render( + + + + {steps[i]} + + + , + container + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Clean up after every iteration. + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + } + + // 6. Verify we can update from each step to each step when moving fallback -> primary. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + act(() => + ReactDOM.render( + + + + {steps[j]} + + + , + container + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[i]); + // Re-render with steps[j]. + act(() => + ReactDOM.render( + + + + {steps[j]} + + + , + container + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(snapshots[j]); + // Check that we can transition back again. + act(() => + ReactDOM.render( + + + + {steps[j]} + + + , + container + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[i]); + // Clean up after every iteration. + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + } + + // 7. Verify we can update from each step to each step when toggling Suspense. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + act(() => + ReactDOM.render( + + + + {steps[i]} + + + , + container + ) + ); + + // We get ID from the index in the tree above: + // Root, X, Suspense, ... + // ^ (index is 2) + const suspenseID = store.getElementIDAtIndex(2); + + // Force fallback. + expect(print(store)).toEqual(snapshots[i]); + act(() => { + const suspenseID = store.getElementIDAtIndex(2); + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: true, + }); + }); + expect(print(store)).toEqual(fallbackSnapshots[j]); + + // Stop forcing fallback. + act(() => { + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: false, + }); + }); + expect(print(store)).toEqual(snapshots[i]); + + // Trigger actual fallback. + act(() => + ReactDOM.render( + + + + {steps[i]} + + + , + container + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[j]); + + // Force fallback while we're in fallback mode. + act(() => { + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: true, + }); + }); + // Keep seeing fallback content. + expect(print(store)).toEqual(fallbackSnapshots[j]); + + // Switch to primary mode. + act(() => + ReactDOM.render( + + + + {steps[i]} + + + , + container + ) + ); + // Fallback is still forced though. + expect(print(store)).toEqual(fallbackSnapshots[j]); + + // Stop forcing fallback. This reverts to primary content. + act(() => { + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: false, + }); + }); + // Now we see primary content. + expect(print(store)).toEqual(snapshots[i]); + + // Clean up after every iteration. + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + } + }); +}); diff --git a/extension/src/__tests__/storeStressTestConcurrent-test.js b/extension/src/__tests__/storeStressTestConcurrent-test.js new file mode 100644 index 0000000000000..d2a834ca98509 --- /dev/null +++ b/extension/src/__tests__/storeStressTestConcurrent-test.js @@ -0,0 +1,1094 @@ +// @flow + +describe('StoreStressConcurrent', () => { + let React; + let ReactDOM; + let act; + let bridge; + let store; + let print; + + beforeEach(() => { + bridge = global.bridge; + store = global.store; + store.collapseNodesByDefault = false; + + React = require('react'); + ReactDOM = require('react-dom'); + act = require('./utils').act; + + print = require('./storeSerializer').print; + }); + + // This is a stress test for the tree mount/update/unmount traversal. + // It renders different trees that should produce the same output. + it('should handle a stress test with different tree operations (Concurrent Mode)', () => { + let setShowX; + const A = () => 'a'; + const B = () => 'b'; + const C = () => { + // We'll be manually flipping this component back and forth in the test. + // We only do this for a single node in order to verify that DevTools + // can handle a subtree switching alternates while other subtrees are memoized. + let [showX, _setShowX] = React.useState(false); + setShowX = _setShowX; + return showX ? : 'c'; + }; + const D = () => 'd'; + const E = () => 'e'; + const X = () => 'x'; + const a =
; + const b = ; + const c = ; + const d = ; + const e = ; + + function Parent({ children }) { + return children; + } + + // 1. Render a normal version of [a, b, c, d, e]. + let container = document.createElement('div'); + // $FlowFixMe + let root = ReactDOM.unstable_createRoot(container); + act(() => root.render({[a, b, c, d, e]})); + expect(store).toMatchSnapshot('1: abcde'); + expect(container.textContent).toMatch('abcde'); + const snapshotForABCDE = print(store); + + // 2. Render a version where renders an child instead of 'c'. + // This is how we'll test an update to a single component. + act(() => { + setShowX(true); + }); + expect(store).toMatchSnapshot('2: abxde'); + expect(container.textContent).toMatch('abxde'); + const snapshotForABXDE = print(store); + + // 3. Verify flipping it back produces the original result. + act(() => { + setShowX(false); + }); + expect(container.textContent).toMatch('abcde'); + expect(print(store)).toBe(snapshotForABCDE); + + // 4. Clean up. + act(() => root.unmount()); + expect(print(store)).toBe(''); + + // Now comes the interesting part. + // All of these cases are equivalent to [a, b, c, d, e] in output. + // We'll verify that DevTools produces the same snapshots for them. + // These cases are picked so that rendering them sequentially in the same + // container results in a combination of mounts, updates, unmounts, and reorders. + // prettier-ignore + let cases = [ + [a, b, c, d, e], + [[a], b, c, d, e], + [[a, b], c, d, e], + [[a, b], c, [d, e]], + [[a, b], c, [d, '', e]], + [[a], b, c, d, [e]], + [a, b, [[c]], d, e], + [[a, ''], [b], [c], [d], [e]], + [a, b, [c, [d, ['', e]]]], + [a, b, c, d, e], + [
{a}
, b, c, d, e], + [
{a}{b}
, c, d, e], + [
{a}{b}
, c,
{d}{e}
], + [
{a}{b}
, c,
{d}{e}
], + [
{a}{b}
, c,
{d}{e}
], + [
{a}{b}
, c,
{d}{e}
], + [{a}, b, c, d, [e]], + [a, b, {c}, d, e], + [
{a}
, [b], {c}, [d],
{e}
], + [a, b, [c,
{d}{e}
], ''], + [a, [[]], b, c, [d, [[]], e]], + [[[a, b, c, d], e]], + [a, b, c, d, e] + ]; + + // 5. Test fresh mount for each case. + for (let i = 0; i < cases.length; i++) { + // Ensure fresh mount. + container = document.createElement('div'); + // $FlowFixMe + root = ReactDOM.unstable_createRoot(container); + + // Verify mounting 'abcde'. + act(() => root.render({cases[i]})); + expect(container.textContent).toMatch('abcde'); + expect(print(store)).toEqual(snapshotForABCDE); + + // Verify switching to 'abxde'. + act(() => { + setShowX(true); + }); + expect(container.textContent).toMatch('abxde'); + expect(print(store)).toBe(snapshotForABXDE); + + // Verify switching back to 'abcde'. + act(() => { + setShowX(false); + }); + expect(container.textContent).toMatch('abcde'); + expect(print(store)).toBe(snapshotForABCDE); + + // Clean up. + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + + // 6. Verify *updates* by reusing the container between iterations. + // There'll be no unmounting until the very end. + container = document.createElement('div'); + // $FlowFixMe + root = ReactDOM.unstable_createRoot(container); + for (let i = 0; i < cases.length; i++) { + // Verify mounting 'abcde'. + act(() => root.render({cases[i]})); + expect(container.textContent).toMatch('abcde'); + expect(print(store)).toEqual(snapshotForABCDE); + + // Verify switching to 'abxde'. + act(() => { + setShowX(true); + }); + expect(container.textContent).toMatch('abxde'); + expect(print(store)).toBe(snapshotForABXDE); + + // Verify switching back to 'abcde'. + act(() => { + setShowX(false); + }); + expect(container.textContent).toMatch('abcde'); + expect(print(store)).toBe(snapshotForABCDE); + // Don't unmount. Reuse the container between iterations. + } + act(() => root.unmount()); + expect(print(store)).toBe(''); + }); + + it('should handle stress test with reordering (Concurrent Mode)', () => { + const A = () => 'a'; + const B = () => 'b'; + const C = () => 'c'; + const D = () => 'd'; + const E = () => 'e'; + const a =
; + const b = ; + const c = ; + const d = ; + const e = ; + + // prettier-ignore + let steps = [ + a, + b, + c, + d, + e, + [a], + [b], + [c], + [d], + [e], + [a, b], + [b, a], + [b, c], + [c, b], + [a, c], + [c, a], + ]; + + const Root = ({ children }) => { + return children; + }; + + // 1. Capture the expected render result. + let snapshots = []; + let container = document.createElement('div'); + // $FlowFixMe + let root = ReactDOM.unstable_createRoot(container); + for (let i = 0; i < steps.length; i++) { + act(() => root.render({steps[i]})); + // We snapshot each step once so it doesn't regress. + expect(store).toMatchSnapshot(); + snapshots.push(print(store)); + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + + // 2. Verify that we can update from every step to every other step and back. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + let container = document.createElement('div'); + // $FlowFixMe + let root = ReactDOM.unstable_createRoot(container); + act(() => root.render({steps[i]})); + expect(print(store)).toMatch(snapshots[i]); + act(() => root.render({steps[j]})); + expect(print(store)).toMatch(snapshots[j]); + act(() => root.render({steps[i]})); + expect(print(store)).toMatch(snapshots[i]); + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + } + + // 3. Same test as above, but this time we wrap children in a host component. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + let container = document.createElement('div'); + // $FlowFixMe + let root = ReactDOM.unstable_createRoot(container); + act(() => + root.render( + +
{steps[i]}
+
+ ) + ); + expect(print(store)).toMatch(snapshots[i]); + act(() => + root.render( + +
{steps[j]}
+
+ ) + ); + expect(print(store)).toMatch(snapshots[j]); + act(() => + root.render( + +
{steps[i]}
+
+ ) + ); + expect(print(store)).toMatch(snapshots[i]); + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + } + }); + + it('should handle a stress test for Suspense (Concurrent Mode)', async () => { + const A = () => 'a'; + const B = () => 'b'; + const C = () => 'c'; + const X = () => 'x'; + const Y = () => 'y'; + const Z = () => 'z'; + const a =
; + const b = ; + const c = ; + const z = ; + + // prettier-ignore + const steps = [ + a, + [a], + [a, b, c], + [c, b, a], + [c, null, a], + {c}{a}, +
{c}{a}
, +
{a}{b}
, + [[a]], + null, + b, + a + ]; + + const Never = () => { + throw new Promise(() => {}); + }; + + const Root = ({ children }) => { + return children; + }; + + // 1. For each step, check Suspense can render them as initial primary content. + // This is the only step where we use Jest snapshots. + let snapshots = []; + let container = document.createElement('div'); + // $FlowFixMe + let root = ReactDOM.unstable_createRoot(container); + for (let i = 0; i < steps.length; i++) { + act(() => + root.render( + + + {steps[i]} + + + ) + ); + // We snapshot each step once so it doesn't regress. + expect(store).toMatchSnapshot(); + snapshots.push(print(store)); + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + + // 2. Verify check Suspense can render same steps as initial fallback content. + for (let i = 0; i < steps.length; i++) { + act(() => + root.render( + + + + + + + + + + ) + ); + expect(print(store)).toEqual(snapshots[i]); + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + + // 3. Verify we can update from each step to each step in primary mode. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + // $FlowFixMe + root = ReactDOM.unstable_createRoot(container); + act(() => + root.render( + + + {steps[i]} + + + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Re-render with steps[j]. + act(() => + root.render( + + + {steps[j]} + + + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(snapshots[j]); + // Check that we can transition back again. + act(() => + root.render( + + + {steps[i]} + + + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Clean up after every iteration. + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + } + + // 4. Verify we can update from each step to each step in fallback mode. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + // $FlowFixMe + root = ReactDOM.unstable_createRoot(container); + act(() => + root.render( + + + + + + + + + + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Re-render with steps[j]. + act(() => + root.render( + + + + + + + + + + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(snapshots[j]); + // Check that we can transition back again. + act(() => + root.render( + + + + + + + + + + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Clean up after every iteration. + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + } + + // 5. Verify we can update from each step to each step when moving primary -> fallback. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + // $FlowFixMe + root = ReactDOM.unstable_createRoot(container); + act(() => + root.render( + + + {steps[i]} + + + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Re-render with steps[j]. + act(() => + root.render( + + + + + + + + + + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(snapshots[j]); + // Check that we can transition back again. + act(() => + root.render( + + + {steps[i]} + + + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Clean up after every iteration. + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + } + + // 6. Verify we can update from each step to each step when moving fallback -> primary. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + // $FlowFixMe + root = ReactDOM.unstable_createRoot(container); + act(() => + root.render( + + + + + + + + + + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Re-render with steps[j]. + act(() => + root.render( + + + {steps[j]} + + + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(snapshots[j]); + // Check that we can transition back again. + act(() => + root.render( + + + + + + + + + + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Clean up after every iteration. + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + } + + // 7. Verify we can update from each step to each step when toggling Suspense. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + // $FlowFixMe + root = ReactDOM.unstable_createRoot(container); + act(() => + root.render( + + + {steps[i]} + + + ) + ); + + // We get ID from the index in the tree above: + // Root, X, Suspense, ... + // ^ (index is 2) + const suspenseID = store.getElementIDAtIndex(2); + + // Force fallback. + expect(print(store)).toEqual(snapshots[i]); + act(() => { + const suspenseID = store.getElementIDAtIndex(2); + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: true, + }); + }); + expect(print(store)).toEqual(snapshots[j]); + + // Stop forcing fallback. + act(() => { + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: false, + }); + }); + expect(print(store)).toEqual(snapshots[i]); + + // Trigger actual fallback. + act(() => + root.render( + + + + + + + + + + ) + ); + expect(print(store)).toEqual(snapshots[j]); + + // Force fallback while we're in fallback mode. + act(() => { + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: true, + }); + }); + // Keep seeing fallback content. + expect(print(store)).toEqual(snapshots[j]); + + // Switch to primary mode. + act(() => + root.render( + + + {steps[i]} + + + ) + ); + // Fallback is still forced though. + expect(print(store)).toEqual(snapshots[j]); + + // Stop forcing fallback. This reverts to primary content. + act(() => { + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: false, + }); + }); + // Now we see primary content. + expect(print(store)).toEqual(snapshots[i]); + + // Clean up after every iteration. + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + } + }); + + it('should handle a stress test for Suspense without type change (Concurrent Mode)', () => { + const A = () => 'a'; + const B = () => 'b'; + const C = () => 'c'; + const X = () => 'x'; + const Y = () => 'y'; + const Z = () => 'z'; + const a =
; + const b = ; + const c = ; + const z = ; + + // prettier-ignore + const steps = [ + a, + [a], + [a, b, c], + [c, b, a], + [c, null, a], + {c}{a}, +
{c}{a}
, +
{a}{b}
, + [[a]], + null, + b, + a + ]; + + const Never = () => { + throw new Promise(() => {}); + }; + + const MaybeSuspend = ({ children, suspend }) => { + if (suspend) { + return ( +
+ {children} + + +
+ ); + } + return ( +
+ {children} + +
+ ); + }; + + const Root = ({ children }) => { + return children; + }; + + // 1. For each step, check Suspense can render them as initial primary content. + // This is the only step where we use Jest snapshots. + let snapshots = []; + let container = document.createElement('div'); + // $FlowFixMe + let root = ReactDOM.unstable_createRoot(container); + for (let i = 0; i < steps.length; i++) { + act(() => + root.render( + + + + {steps[i]} + + + + ) + ); + // We snapshot each step once so it doesn't regress. + expect(store).toMatchSnapshot(); + snapshots.push(print(store)); + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + + // 2. Verify check Suspense can render same steps as initial fallback content. + // We don't actually assert here because the tree includes + // which is different from the snapshots above. So we take more snapshots. + let fallbackSnapshots = []; + for (let i = 0; i < steps.length; i++) { + act(() => + root.render( + + + + + {steps[i]} + + + + + ) + ); + // We snapshot each step once so it doesn't regress. + expect(store).toMatchSnapshot(); + fallbackSnapshots.push(print(store)); + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + + // 3. Verify we can update from each step to each step in primary mode. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + // $FlowFixMe + root = ReactDOM.unstable_createRoot(container); + act(() => + root.render( + + + + {steps[i]} + + + + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Re-render with steps[j]. + act(() => + root.render( + + + + {steps[j]} + + + + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(snapshots[j]); + // Check that we can transition back again. + act(() => + root.render( + + + + {steps[i]} + + + + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Clean up after every iteration. + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + } + + // 4. Verify we can update from each step to each step in fallback mode. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + // $FlowFixMe + root = ReactDOM.unstable_createRoot(container); + act(() => + root.render( + + + + + + + + + + + + + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[i]); + // Re-render with steps[j]. + act(() => + root.render( + + + + + + + + + + + + + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(fallbackSnapshots[j]); + // Check that we can transition back again. + act(() => + root.render( + + + + + + + + + + + + + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[i]); + // Clean up after every iteration. + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + } + + // 5. Verify we can update from each step to each step when moving primary -> fallback. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + // $FlowFixMe + root = ReactDOM.unstable_createRoot(container); + act(() => + root.render( + + + + {steps[i]} + + + + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Re-render with steps[j]. + act(() => + root.render( + + + + {steps[i]} + + + + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(fallbackSnapshots[j]); + // Check that we can transition back again. + act(() => + root.render( + + + + {steps[i]} + + + + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Clean up after every iteration. + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + } + + // 6. Verify we can update from each step to each step when moving fallback -> primary. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + // $FlowFixMe + root = ReactDOM.unstable_createRoot(container); + act(() => + root.render( + + + + {steps[j]} + + + + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[i]); + // Re-render with steps[j]. + act(() => + root.render( + + + + {steps[j]} + + + + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(snapshots[j]); + // Check that we can transition back again. + act(() => + root.render( + + + + {steps[j]} + + + + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[i]); + // Clean up after every iteration. + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + } + + // 7. Verify we can update from each step to each step when toggling Suspense. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + // $FlowFixMe + root = ReactDOM.unstable_createRoot(container); + act(() => + root.render( + + + + {steps[i]} + + + + ) + ); + + // We get ID from the index in the tree above: + // Root, X, Suspense, ... + // ^ (index is 2) + const suspenseID = store.getElementIDAtIndex(2); + + // Force fallback. + expect(print(store)).toEqual(snapshots[i]); + act(() => { + const suspenseID = store.getElementIDAtIndex(2); + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: true, + }); + }); + expect(print(store)).toEqual(fallbackSnapshots[j]); + + // Stop forcing fallback. + act(() => { + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: false, + }); + }); + expect(print(store)).toEqual(snapshots[i]); + + // Trigger actual fallback. + act(() => + root.render( + + + + {steps[i]} + + + + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[j]); + + // Force fallback while we're in fallback mode. + act(() => { + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: true, + }); + }); + // Keep seeing fallback content. + expect(print(store)).toEqual(fallbackSnapshots[j]); + + // Switch to primary mode. + act(() => + root.render( + + + + {steps[i]} + + + + ) + ); + // Fallback is still forced though. + expect(print(store)).toEqual(fallbackSnapshots[j]); + + // Stop forcing fallback. This reverts to primary content. + act(() => { + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: false, + }); + }); + // Now we see primary content. + expect(print(store)).toEqual(snapshots[i]); + + // Clean up after every iteration. + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + } + }); +}); diff --git a/extension/src/__tests__/treeContext-test.js b/extension/src/__tests__/treeContext-test.js new file mode 100644 index 0000000000000..68d3ea422b496 --- /dev/null +++ b/extension/src/__tests__/treeContext-test.js @@ -0,0 +1,608 @@ +// @flow + +import typeof ReactTestRenderer from 'react-test-renderer'; +import type { FrontendBridge } from 'src/bridge'; +import type Store from 'src/devtools/store'; +import type { + DispatcherContext, + StateContext, +} from 'src/devtools/views/Components/TreeContext'; + +describe('TreeListContext', () => { + let React; + let ReactDOM; + let TestRenderer: ReactTestRenderer; + let bridge: FrontendBridge; + let store: Store; + let utils; + + let BridgeContext; + let StoreContext; + let TreeContext; + + let dispatch: DispatcherContext; + let state: StateContext; + + beforeEach(() => { + utils = require('./utils'); + utils.beforeEachProfiling(); + + bridge = global.bridge; + store = global.store; + store.collapseNodesByDefault = false; + + React = require('react'); + ReactDOM = require('react-dom'); + TestRenderer = utils.requireTestRenderer(); + + BridgeContext = require('src/devtools/views/context').BridgeContext; + StoreContext = require('src/devtools/views/context').StoreContext; + TreeContext = require('src/devtools/views/Components/TreeContext'); + }); + + afterEach(() => { + // Reset between tests + dispatch = ((null: any): DispatcherContext); + state = ((null: any): StateContext); + }); + + const Capture = () => { + dispatch = React.useContext(TreeContext.TreeDispatcherContext); + state = React.useContext(TreeContext.TreeStateContext); + return null; + }; + + const Contexts = () => { + return ( + + + + + + + + ); + }; + + describe('tree state', () => { + it('should select the next and previous elements in the tree', () => { + const Grandparent = () => ; + const Parent = () => ( + + + + + ); + const Child = () => null; + + utils.act(() => + ReactDOM.render(, document.createElement('div')) + ); + + expect(store).toMatchSnapshot('0: mount'); + + let renderer; + utils.act(() => (renderer = TestRenderer.create())); + expect(state).toMatchSnapshot('1: initial state'); + + utils.act(() => dispatch({ type: 'SELECT_NEXT_ELEMENT_IN_TREE' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('2: select first element'); + + while ( + state.selectedElementIndex !== null && + state.selectedElementIndex < store.numElements - 1 + ) { + const index = ((state.selectedElementIndex: any): number); + utils.act(() => dispatch({ type: 'SELECT_NEXT_ELEMENT_IN_TREE' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot(`3: select element after (${index})`); + } + + while ( + state.selectedElementIndex !== null && + state.selectedElementIndex > 0 + ) { + const index = ((state.selectedElementIndex: any): number); + utils.act(() => dispatch({ type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot(`4: select element before (${index})`); + } + + utils.act(() => dispatch({ type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('5: select previous wraps around to last'); + + utils.act(() => dispatch({ type: 'SELECT_NEXT_ELEMENT_IN_TREE' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('6: select next wraps around to first'); + }); + + it('should select child elements', () => { + const Grandparent = () => ( + + + + + ); + const Parent = () => ( + + + + + ); + const Child = () => null; + + utils.act(() => + ReactDOM.render(, document.createElement('div')) + ); + + expect(store).toMatchSnapshot('0: mount'); + + let renderer; + utils.act(() => (renderer = TestRenderer.create())); + expect(state).toMatchSnapshot('1: initial state'); + + utils.act(() => + dispatch({ type: 'SELECT_ELEMENT_AT_INDEX', payload: 0 }) + ); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('2: select first element'); + + utils.act(() => dispatch({ type: 'SELECT_CHILD_ELEMENT_IN_TREE' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('3: select Parent'); + + utils.act(() => dispatch({ type: 'SELECT_CHILD_ELEMENT_IN_TREE' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('4: select Child'); + + const previousState = state; + + // There are no more children to select, so this should be a no-op + utils.act(() => dispatch({ type: 'SELECT_CHILD_ELEMENT_IN_TREE' })); + utils.act(() => renderer.update()); + expect(state).toEqual(previousState); + }); + + it('should select parent elements and then collapse', () => { + const Grandparent = () => ( + + + + + ); + const Parent = () => ( + + + + + ); + const Child = () => null; + + utils.act(() => + ReactDOM.render(, document.createElement('div')) + ); + + expect(store).toMatchSnapshot('0: mount'); + + let renderer; + utils.act(() => (renderer = TestRenderer.create())); + expect(state).toMatchSnapshot('1: initial state'); + + const lastChildID = store.getElementIDAtIndex(store.numElements - 1); + + utils.act(() => + dispatch({ type: 'SELECT_ELEMENT_BY_ID', payload: lastChildID }) + ); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('2: select last child'); + + utils.act(() => dispatch({ type: 'SELECT_PARENT_ELEMENT_IN_TREE' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('3: select Parent'); + + utils.act(() => dispatch({ type: 'SELECT_PARENT_ELEMENT_IN_TREE' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('4: select Grandparent'); + + const previousState = state; + + // There are no more ancestors to select, so this should be a no-op + utils.act(() => dispatch({ type: 'SELECT_PARENT_ELEMENT_IN_TREE' })); + utils.act(() => renderer.update()); + expect(state).toEqual(previousState); + }); + + it('should clear selection if the selected element is unmounted', async done => { + const Grandparent = props => props.children || null; + const Parent = props => props.children || null; + const Child = () => null; + + const container = document.createElement('div'); + utils.act(() => + ReactDOM.render( + + + + + + , + container + ) + ); + + expect(store).toMatchSnapshot('0: mount'); + + let renderer; + utils.act(() => (renderer = TestRenderer.create())); + expect(state).toMatchSnapshot('1: initial state'); + + utils.act(() => + dispatch({ type: 'SELECT_ELEMENT_AT_INDEX', payload: 3 }) + ); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('2: select second child'); + + await utils.actAsync(() => + ReactDOM.render( + + + , + container + ) + ); + expect(state).toMatchSnapshot( + '3: remove children (parent should now be selected)' + ); + + await utils.actAsync(() => ReactDOM.unmountComponentAtNode(container)); + expect(state).toMatchSnapshot( + '4: unmount root (nothing should be selected)' + ); + + done(); + }); + }); + + describe('search state', () => { + it('should find elements matching search text', () => { + const Foo = () => null; + const Bar = () => null; + const Baz = () => null; + const Qux = () => null; + + Qux.displayName = `withHOC(${Qux.name})`; + + utils.act(() => + ReactDOM.render( + + + + + + , + document.createElement('div') + ) + ); + + expect(store).toMatchSnapshot('0: mount'); + + let renderer; + utils.act(() => (renderer = TestRenderer.create())); + expect(state).toMatchSnapshot('1: initial state'); + + // NOTE: multi-match + utils.act(() => dispatch({ type: 'SET_SEARCH_TEXT', payload: 'ba' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('2: search for "ba"'); + + // NOTE: single match + utils.act(() => dispatch({ type: 'SET_SEARCH_TEXT', payload: 'f' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('3: search for "f"'); + + // NOTE: no match + utils.act(() => dispatch({ type: 'SET_SEARCH_TEXT', payload: 'y' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('4: search for "y"'); + + // NOTE: HOC match + utils.act(() => dispatch({ type: 'SET_SEARCH_TEXT', payload: 'w' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('5: search for "w"'); + }); + + it('should select the next and previous items within the search results', () => { + const Foo = () => null; + const Bar = () => null; + const Baz = () => null; + + utils.act(() => + ReactDOM.render( + + + + + + , + document.createElement('div') + ) + ); + + expect(store).toMatchSnapshot('0: mount'); + + let renderer; + utils.act(() => (renderer = TestRenderer.create())); + expect(state).toMatchSnapshot('1: initial state'); + + utils.act(() => dispatch({ type: 'SET_SEARCH_TEXT', payload: 'ba' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('2: search for "ba"'); + + utils.act(() => dispatch({ type: 'GO_TO_NEXT_SEARCH_RESULT' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('3: go to second result'); + + utils.act(() => dispatch({ type: 'GO_TO_NEXT_SEARCH_RESULT' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('4: go to third result'); + + utils.act(() => dispatch({ type: 'GO_TO_PREVIOUS_SEARCH_RESULT' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('5: go to second result'); + + utils.act(() => dispatch({ type: 'GO_TO_PREVIOUS_SEARCH_RESULT' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('6: go to first result'); + + utils.act(() => dispatch({ type: 'GO_TO_PREVIOUS_SEARCH_RESULT' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('7: wrap to last result'); + + utils.act(() => dispatch({ type: 'GO_TO_NEXT_SEARCH_RESULT' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('8: wrap to first result'); + }); + + it('should add newly mounted elements to the search results set if they match the current text', async done => { + const Foo = () => null; + const Bar = () => null; + const Baz = () => null; + + const container = document.createElement('div'); + + utils.act(() => + ReactDOM.render( + + + + , + container + ) + ); + + expect(store).toMatchSnapshot('0: mount'); + + let renderer; + utils.act(() => (renderer = TestRenderer.create())); + expect(state).toMatchSnapshot('1: initial state'); + + utils.act(() => dispatch({ type: 'SET_SEARCH_TEXT', payload: 'ba' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('2: search for "ba"'); + + await utils.actAsync(() => + ReactDOM.render( + + + + + , + container + ) + ); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('3: mount Baz'); + + done(); + }); + + it('should remove unmounted elements from the search results set', async done => { + const Foo = () => null; + const Bar = () => null; + const Baz = () => null; + + const container = document.createElement('div'); + + utils.act(() => + ReactDOM.render( + + + + + , + container + ) + ); + + expect(store).toMatchSnapshot('0: mount'); + + let renderer; + utils.act(() => (renderer = TestRenderer.create())); + expect(state).toMatchSnapshot('1: initial state'); + + utils.act(() => dispatch({ type: 'SET_SEARCH_TEXT', payload: 'ba' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('2: search for "ba"'); + + utils.act(() => dispatch({ type: 'GO_TO_NEXT_SEARCH_RESULT' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('3: go to second result'); + + await utils.actAsync(() => + ReactDOM.render( + + + + , + container + ) + ); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('4: unmount Baz'); + + done(); + }); + }); + + describe('owners state', () => { + it('should support entering and existing the owners tree view', () => { + const Grandparent = () => ; + const Parent = () => ( + + + + + ); + const Child = () => null; + + utils.act(() => + ReactDOM.render(, document.createElement('div')) + ); + + expect(store).toMatchSnapshot('0: mount'); + + let renderer; + utils.act(() => (renderer = TestRenderer.create())); + expect(state).toMatchSnapshot('1: initial state'); + + let parentID = ((store.getElementIDAtIndex(1): any): number); + utils.act(() => dispatch({ type: 'SELECT_OWNER', payload: parentID })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('2: parent owners tree'); + + utils.act(() => dispatch({ type: 'RESET_OWNER_STACK' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('3: final state'); + }); + + it('should remove an element from the owners list if it is unmounted', async done => { + const Grandparent = ({ count }) => ; + const Parent = ({ count }) => + new Array(count).fill(true).map((_, index) => ); + const Child = () => null; + + const container = document.createElement('div'); + utils.act(() => ReactDOM.render(, container)); + + expect(store).toMatchSnapshot('0: mount'); + + let renderer; + utils.act(() => (renderer = TestRenderer.create())); + expect(state).toMatchSnapshot('1: initial state'); + + let parentID = ((store.getElementIDAtIndex(1): any): number); + utils.act(() => dispatch({ type: 'SELECT_OWNER', payload: parentID })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('2: parent owners tree'); + + await utils.actAsync(() => + ReactDOM.render(, container) + ); + expect(state).toMatchSnapshot('3: remove second child'); + + await utils.actAsync(() => + ReactDOM.render(, container) + ); + expect(state).toMatchSnapshot('4: remove first child'); + + done(); + }); + + it('should exit the owners list if the current owner is unmounted', async done => { + const Parent = props => props.children || null; + const Child = () => null; + + const container = document.createElement('div'); + utils.act(() => + ReactDOM.render( + + + , + container + ) + ); + + expect(store).toMatchSnapshot('0: mount'); + + let renderer; + utils.act(() => (renderer = TestRenderer.create())); + expect(state).toMatchSnapshot('1: initial state'); + + let childID = ((store.getElementIDAtIndex(1): any): number); + utils.act(() => dispatch({ type: 'SELECT_OWNER', payload: childID })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('2: child owners tree'); + + await utils.actAsync(() => ReactDOM.render(, container)); + expect(state).toMatchSnapshot('3: remove child'); + + let parentID = ((store.getElementIDAtIndex(0): any): number); + utils.act(() => dispatch({ type: 'SELECT_OWNER', payload: parentID })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('4: parent owners tree'); + + await utils.actAsync(() => ReactDOM.unmountComponentAtNode(container)); + expect(state).toMatchSnapshot('5: unmount root'); + + done(); + }); + + // This tests ensures support for toggling Suspense boundaries outside of the active owners list. + it('should exit the owners list if an element outside the list is selected', () => { + const Grandchild = () => null; + const Child = () => ( + + + + ); + const Parent = () => ( + + + + ); + + const container = document.createElement('div'); + utils.act(() => ReactDOM.render(, container)); + + expect(store).toMatchSnapshot('0: mount'); + + let renderer; + utils.act(() => (renderer = TestRenderer.create())); + expect(state).toMatchSnapshot('1: initial state'); + + const outerSuspenseID = ((store.getElementIDAtIndex(1): any): number); + const childID = ((store.getElementIDAtIndex(2): any): number); + const innerSuspenseID = ((store.getElementIDAtIndex(3): any): number); + + utils.act(() => dispatch({ type: 'SELECT_OWNER', payload: childID })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('2: child owners tree'); + + // Toggling a Suspense boundary inside of the flat list should update selected index + utils.act(() => + dispatch({ type: 'SELECT_ELEMENT_BY_ID', payload: innerSuspenseID }) + ); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('3: child owners tree'); + + // Toggling a Suspense boundary outside of the flat list should exit owners list and update index + utils.act(() => + dispatch({ type: 'SELECT_ELEMENT_BY_ID', payload: outerSuspenseID }) + ); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('4: main tree'); + }); + }); +}); diff --git a/extension/src/__tests__/utils.js b/extension/src/__tests__/utils.js new file mode 100644 index 0000000000000..bf0c816109fa0 --- /dev/null +++ b/extension/src/__tests__/utils.js @@ -0,0 +1,203 @@ +// @flow + +import typeof ReactTestRenderer from 'react-test-renderer'; + +import type { FrontendBridge } from 'src/bridge'; +import type Store from 'src/devtools/store'; +import type { ProfilingDataFrontend } from 'src/devtools/views/Profiler/types'; +import type { ElementType } from 'src/types'; + +export function act(callback: Function): void { + const { act: actTestRenderer } = require('react-test-renderer'); + const { act: actDOM } = require('react-dom/test-utils'); + + actDOM(() => { + actTestRenderer(() => { + callback(); + }); + }); + + // Flush Bridge operations + actDOM(() => { + actTestRenderer(() => { + jest.runAllTimers(); + }); + }); +} + +export async function actAsync( + cb: () => *, + recursivelyFlush: boolean = true +): Promise { + const { act: actTestRenderer } = require('react-test-renderer'); + const { act: actDOM } = require('react-dom/test-utils'); + + // $FlowFixMe Flow doens't know about "await act()" yet + await actDOM(async () => { + await actTestRenderer(async () => { + await cb(); + }); + }); + + if (recursivelyFlush) { + while (jest.getTimerCount() > 0) { + // $FlowFixMe Flow doens't know about "await act()" yet + await actDOM(async () => { + await actTestRenderer(async () => { + jest.runAllTimers(); + }); + }); + } + } else { + // $FlowFixMe Flow doesn't know about "await act()" yet + await actDOM(async () => { + await actTestRenderer(async () => { + jest.runOnlyPendingTimers(); + }); + }); + } +} + +export function beforeEachProfiling(): void { + // Mock React's timing information so that test runs are predictable. + jest.mock('scheduler', () => jest.requireActual('scheduler/unstable_mock')); + + // DevTools itself uses performance.now() to offset commit times + // so they appear relative to when profiling was started in the UI. + jest + .spyOn(performance, 'now') + .mockImplementation( + jest.requireActual('scheduler/unstable_mock').unstable_now + ); +} + +export function createDisplayNameFilter( + source: string, + isEnabled: boolean = true +) { + const Types = require('src/types'); + let isValid = true; + try { + new RegExp(source); + } catch (error) { + isValid = false; + } + return { + type: Types.ComponentFilterDisplayName, + isEnabled, + isValid, + value: source, + }; +} + +export function createHOCFilter(isEnabled: boolean = true) { + const Types = require('src/types'); + return { + type: Types.ComponentFilterHOC, + isEnabled, + isValid: true, + }; +} + +export function createElementTypeFilter( + elementType: ElementType, + isEnabled: boolean = true +) { + const Types = require('src/types'); + return { + type: Types.ComponentFilterElementType, + isEnabled, + value: elementType, + }; +} + +export function createLocationFilter( + source: string, + isEnabled: boolean = true +) { + const Types = require('src/types'); + let isValid = true; + try { + new RegExp(source); + } catch (error) { + isValid = false; + } + return { + type: Types.ComponentFilterLocation, + isEnabled, + isValid, + value: source, + }; +} + +export function getRendererID(): number { + if (global.agent == null) { + throw Error('Agent unavailable.'); + } + const ids = Object.keys(global.agent._rendererInterfaces); + + const id = ids.find(id => { + const rendererInterface = global.agent._rendererInterfaces[id]; + return rendererInterface.renderer.rendererPackageName === 'react-dom'; + }); + + if (ids == null) { + throw Error('Could not find renderer.'); + } + + return parseInt(id, 10); +} + +export function requireTestRenderer(): ReactTestRenderer { + let hook; + try { + // Hide the hook before requiring TestRenderer, so we don't end up with a loop. + hook = global.__REACT_DEVTOOLS_GLOBAL_HOOK__; + delete global.__REACT_DEVTOOLS_GLOBAL_HOOK__; + + return require('react-test-renderer'); + } finally { + global.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook; + } +} + +export function exportImportHelper(bridge: FrontendBridge, store: Store): void { + const { act } = require('./utils'); + const { + prepareProfilingDataExport, + prepareProfilingDataFrontendFromExport, + } = require('src/devtools/views/Profiler/utils'); + + const { profilerStore } = store; + + expect(profilerStore.profilingData).not.toBeNull(); + + const profilingDataFrontendInitial = ((profilerStore.profilingData: any): ProfilingDataFrontend); + + const profilingDataExport = prepareProfilingDataExport( + profilingDataFrontendInitial + ); + + // Simulate writing/reading to disk. + const serializedProfilingDataExport = JSON.stringify( + profilingDataExport, + null, + 2 + ); + const parsedProfilingDataExport = JSON.parse(serializedProfilingDataExport); + + const profilingDataFrontend = prepareProfilingDataFrontendFromExport( + (parsedProfilingDataExport: any) + ); + + // Sanity check that profiling snapshots are serialized correctly. + expect(profilingDataFrontendInitial).toEqual(profilingDataFrontend); + + // Snapshot the JSON-parsed object, rather than the raw string, because Jest formats the diff nicer. + expect(parsedProfilingDataExport).toMatchSnapshot('imported data'); + + act(() => { + // Apply the new exported-then-reimported data so tests can re-run assertions. + profilerStore.profilingData = profilingDataFrontend; + }); +} diff --git a/extension/src/backend/NativeStyleEditor/resolveBoxStyle.js b/extension/src/backend/NativeStyleEditor/resolveBoxStyle.js new file mode 100644 index 0000000000000..0609272dc9050 --- /dev/null +++ b/extension/src/backend/NativeStyleEditor/resolveBoxStyle.js @@ -0,0 +1,85 @@ +// @flow + +import type { BoxStyle } from './types'; + +/** + * This mirrors react-native/Libraries/Inspector/resolveBoxStyle.js (but without RTL support). + * + * Resolve a style property into it's component parts, e.g. + * + * resolveBoxStyle('margin', {margin: 5, marginBottom: 10}) + * -> {top: 5, left: 5, right: 5, bottom: 10} + */ +export default function resolveBoxStyle( + prefix: string, + style: Object +): BoxStyle | null { + let hasParts = false; + const result = { + bottom: 0, + left: 0, + right: 0, + top: 0, + }; + + const styleForAll = style[prefix]; + if (styleForAll != null) { + for (const key of Object.keys(result)) { + result[key] = styleForAll; + } + hasParts = true; + } + + const styleForHorizontal = style[prefix + 'Horizontal']; + if (styleForHorizontal != null) { + result.left = styleForHorizontal; + result.right = styleForHorizontal; + hasParts = true; + } else { + const styleForLeft = style[prefix + 'Left']; + if (styleForLeft != null) { + result.left = styleForLeft; + hasParts = true; + } + + const styleForRight = style[prefix + 'Right']; + if (styleForRight != null) { + result.right = styleForRight; + hasParts = true; + } + + const styleForEnd = style[prefix + 'End']; + if (styleForEnd != null) { + // TODO RTL support + result.right = styleForEnd; + hasParts = true; + } + const styleForStart = style[prefix + 'Start']; + if (styleForStart != null) { + // TODO RTL support + result.left = styleForStart; + hasParts = true; + } + } + + const styleForVertical = style[prefix + 'Vertical']; + if (styleForVertical != null) { + result.bottom = styleForVertical; + result.top = styleForVertical; + hasParts = true; + } else { + const styleForBottom = style[prefix + 'Bottom']; + if (styleForBottom != null) { + result.bottom = styleForBottom; + hasParts = true; + } + + const styleForTop = style[prefix + 'Top']; + if (styleForTop != null) { + result.top = styleForTop; + hasParts = true; + } + } + + return hasParts ? result : null; +} diff --git a/extension/src/backend/NativeStyleEditor/setupNativeStyleEditor.js b/extension/src/backend/NativeStyleEditor/setupNativeStyleEditor.js new file mode 100644 index 0000000000000..642dbb91eb87e --- /dev/null +++ b/extension/src/backend/NativeStyleEditor/setupNativeStyleEditor.js @@ -0,0 +1,315 @@ +// @flow + +import Agent from 'src/backend/agent'; +import resolveBoxStyle from './resolveBoxStyle'; + +import type { BackendBridge } from 'src/bridge'; +import type { RendererID } from '../types'; +import type { StyleAndLayout } from './types'; + +export type ResolveNativeStyle = (stylesheetID: number) => ?Object; + +export default function setupNativeStyleEditor( + bridge: BackendBridge, + agent: Agent, + resolveNativeStyle: ResolveNativeStyle, + validAttributes?: $ReadOnlyArray | null +) { + bridge.addListener( + 'NativeStyleEditor_measure', + ({ id, rendererID }: {| id: number, rendererID: RendererID |}) => { + measureStyle(agent, bridge, resolveNativeStyle, id, rendererID); + } + ); + + bridge.addListener( + 'NativeStyleEditor_renameAttribute', + ({ + id, + rendererID, + oldName, + newName, + value, + }: {| + id: number, + rendererID: RendererID, + oldName: string, + newName: string, + value: string, + |}) => { + renameStyle(agent, id, rendererID, oldName, newName, value); + setTimeout(() => + measureStyle(agent, bridge, resolveNativeStyle, id, rendererID) + ); + } + ); + + bridge.addListener( + 'NativeStyleEditor_setValue', + ({ + id, + rendererID, + name, + value, + }: {| + id: number, + rendererID: number, + name: string, + value: string, + |}) => { + setStyle(agent, id, rendererID, name, value); + setTimeout(() => + measureStyle(agent, bridge, resolveNativeStyle, id, rendererID) + ); + } + ); + + bridge.send('isNativeStyleEditorSupported', { + isSupported: true, + validAttributes, + }); +} + +const EMPTY_BOX_STYLE = { + top: 0, + left: 0, + right: 0, + bottom: 0, +}; + +const componentIDToStyleOverrides: Map = new Map(); + +function measureStyle( + agent: Agent, + bridge: BackendBridge, + resolveNativeStyle: ResolveNativeStyle, + id: number, + rendererID: RendererID +) { + const data = agent.getInstanceAndStyle({ id, rendererID }); + if (!data || !data.style) { + bridge.send( + 'NativeStyleEditor_styleAndLayout', + ({ + id, + layout: null, + style: null, + }: StyleAndLayout) + ); + return; + } + + const { instance, style } = data; + + let resolvedStyle = resolveNativeStyle(style); + + // If it's a host component we edited before, amend styles. + const styleOverrides = componentIDToStyleOverrides.get(id); + if (styleOverrides != null) { + resolvedStyle = Object.assign({}, resolvedStyle, styleOverrides); + } + + if (!instance || typeof instance.measure !== 'function') { + bridge.send( + 'NativeStyleEditor_styleAndLayout', + ({ + id, + layout: null, + style: resolvedStyle || null, + }: StyleAndLayout) + ); + return; + } + + // $FlowFixMe the parameter types of an unknown function are unknown + instance.measure((x, y, width, height, left, top) => { + // RN Android sometimes returns undefined here. Don't send measurements in this case. + // https://github.com/jhen0409/react-native-debugger/issues/84#issuecomment-304611817 + if (typeof x !== 'number') { + bridge.send( + 'NativeStyleEditor_styleAndLayout', + ({ + id, + layout: null, + style: resolvedStyle || null, + }: StyleAndLayout) + ); + return; + } + const margin = resolveBoxStyle('margin', resolvedStyle) || EMPTY_BOX_STYLE; + const padding = + resolveBoxStyle('padding', resolvedStyle) || EMPTY_BOX_STYLE; + bridge.send( + 'NativeStyleEditor_styleAndLayout', + ({ + id, + layout: { + x, + y, + width, + height, + left, + top, + margin, + padding, + }, + style: resolvedStyle || null, + }: StyleAndLayout) + ); + }); +} + +function shallowClone(object: Object): Object { + const cloned = {}; + for (let n in object) { + cloned[n] = object[n]; + } + return cloned; +} + +function renameStyle( + agent: Agent, + id: number, + rendererID: RendererID, + oldName: string, + newName: string, + value: string +): void { + const data = agent.getInstanceAndStyle({ id, rendererID }); + if (!data || !data.style) { + return; + } + + const { instance, style } = data; + + const newStyle = newName + ? { [oldName]: undefined, [newName]: value } + : { [oldName]: undefined }; + + let customStyle; + + // TODO It would be nice if the renderer interface abstracted this away somehow. + if (instance !== null && typeof instance.setNativeProps === 'function') { + // In the case of a host component, we need to use setNativeProps(). + // Remember to "correct" resolved styles when we read them next time. + const styleOverrides = componentIDToStyleOverrides.get(id); + if (!styleOverrides) { + componentIDToStyleOverrides.set(id, newStyle); + } else { + Object.assign(styleOverrides, newStyle); + } + // TODO Fabric does not support setNativeProps; chat with Sebastian or Eli + instance.setNativeProps({ style: newStyle }); + } else if (Array.isArray(style)) { + const lastIndex = style.length - 1; + if ( + typeof style[lastIndex] === 'object' && + !Array.isArray(style[lastIndex]) + ) { + customStyle = shallowClone(style[lastIndex]); + delete customStyle[oldName]; + if (newName) { + customStyle[newName] = value; + } else { + customStyle[oldName] = undefined; + } + + agent.overrideProps({ + id, + rendererID, + path: ['style', lastIndex], + value: customStyle, + }); + } else { + agent.overrideProps({ + id, + rendererID, + path: ['style'], + value: style.concat([newStyle]), + }); + } + } else if (typeof style === 'object') { + customStyle = shallowClone(style); + delete customStyle[oldName]; + if (newName) { + customStyle[newName] = value; + } else { + customStyle[oldName] = undefined; + } + + agent.overrideProps({ + id, + rendererID, + path: ['style'], + value: customStyle, + }); + } else { + agent.overrideProps({ + id, + rendererID, + path: ['style'], + value: [style, newStyle], + }); + } + + agent.emit('hideNativeHighlight'); +} + +function setStyle( + agent: Agent, + id: number, + rendererID: RendererID, + name: string, + value: string +) { + const data = agent.getInstanceAndStyle({ id, rendererID }); + if (!data || !data.style) { + return; + } + + const { instance, style } = data; + const newStyle = { [name]: value }; + + // TODO It would be nice if the renderer interface abstracted this away somehow. + if (instance !== null && typeof instance.setNativeProps === 'function') { + // In the case of a host component, we need to use setNativeProps(). + // Remember to "correct" resolved styles when we read them next time. + const styleOverrides = componentIDToStyleOverrides.get(id); + if (!styleOverrides) { + componentIDToStyleOverrides.set(id, newStyle); + } else { + Object.assign(styleOverrides, newStyle); + } + // TODO Fabric does not support setNativeProps; chat with Sebastian or Eli + instance.setNativeProps({ style: newStyle }); + } else if (Array.isArray(style)) { + const lastLength = style.length - 1; + if ( + typeof style[lastLength] === 'object' && + !Array.isArray(style[lastLength]) + ) { + agent.overrideProps({ + id, + rendererID, + path: ['style', lastLength, name], + value, + }); + } else { + agent.overrideProps({ + id, + rendererID, + path: ['style'], + value: style.concat([newStyle]), + }); + } + } else { + agent.overrideProps({ + id, + rendererID, + path: ['style'], + value: [style, newStyle], + }); + } + + agent.emit('hideNativeHighlight'); +} diff --git a/extension/src/backend/NativeStyleEditor/types.js b/extension/src/backend/NativeStyleEditor/types.js new file mode 100644 index 0000000000000..f7040cb8d3049 --- /dev/null +++ b/extension/src/backend/NativeStyleEditor/types.js @@ -0,0 +1,27 @@ +// @flow + +export type BoxStyle = $ReadOnly<{| + bottom: number, + left: number, + right: number, + top: number, +|}>; + +export type Layout = {| + x: number, + y: number, + width: number, + height: number, + left: number, + top: number, + margin: BoxStyle, + padding: BoxStyle, +|}; + +export type Style = Object; + +export type StyleAndLayout = {| + id: number, + style: Style | null, + layout: Layout | null, +|}; diff --git a/extension/src/backend/ReactDebugHooks.js b/extension/src/backend/ReactDebugHooks.js new file mode 100644 index 0000000000000..6baaa2530031b --- /dev/null +++ b/extension/src/backend/ReactDebugHooks.js @@ -0,0 +1,636 @@ +// @flow + +// This file was forked from the React GitHub repo: +// https://github.com/facebook/react/blob/master/packages/react-debug-tools/src/ReactDebugHooks.js +// It has been modified slightly though to account for "shared" imports and different lint configs. +// I've also removed some of the Flow types that don't exist in DevTools. +// TODO Remove this fork and use the NPM version of this package once it's released. + +import ErrorStackParser from 'error-stack-parser'; + +import type { + ReactContext, + ReactEventResponder, + ReactEventResponderListener, +} from './types'; + +type Fiber = any; +type Hook = any; + +// HACK: These values are copied from attachRendererFiber +// In the future, the react-debug-hooks package will be published to NPM, +// and be locked to a specific range of react versions +// For now we are just hard-coding the current/latest versions. +const ContextProvider = 10; +const ForwardRef = 11; +const FunctionComponent = 0; +const SimpleMemoComponent = 15; + +// Used to track hooks called during a render + +type HookLogEntry = { + primitive: string, + stackError: Error, + value: mixed, +}; + +let hookLog: Array = []; + +// Primitives + +type BasicStateAction = (S => S) | S; + +type Dispatch
= A => void; + +let primitiveStackCache: null | Map> = null; + +function getPrimitiveStackCache(): Map> { + // This initializes a cache of all primitive hooks so that the top + // most stack frames added by calling the primitive hook can be removed. + if (primitiveStackCache === null) { + const cache = new Map(); + let readHookLog; + try { + // Use all hooks here to add them to the hook log. + Dispatcher.useContext(({ _currentValue: null }: any)); + Dispatcher.useState(null); + Dispatcher.useReducer((s, a) => s, null); + Dispatcher.useRef(null); + Dispatcher.useLayoutEffect(() => {}); + Dispatcher.useEffect(() => {}); + Dispatcher.useImperativeHandle(undefined, () => null); + Dispatcher.useDebugValue(null); + Dispatcher.useCallback(() => {}); + Dispatcher.useMemo(() => null); + } finally { + readHookLog = hookLog; + hookLog = []; + } + for (let i = 0; i < readHookLog.length; i++) { + const hook = readHookLog[i]; + cache.set(hook.primitive, ErrorStackParser.parse(hook.stackError)); + } + primitiveStackCache = cache; + } + return primitiveStackCache; +} + +let currentHook: null | Hook = null; +function nextHook(): null | Hook { + const hook = currentHook; + if (hook !== null) { + currentHook = hook.next; + } + return hook; +} + +function readContext( + context: ReactContext, + observedBits: void | number | boolean +): T { + // For now we don't expose readContext usage in the hooks debugging info. + return context._currentValue; +} + +function useContext( + context: ReactContext, + observedBits: void | number | boolean +): T { + hookLog.push({ + primitive: 'Context', + stackError: new Error(), + value: context._currentValue, + }); + return context._currentValue; +} + +function useState( + initialState: (() => S) | S +): [S, Dispatch>] { + const hook = nextHook(); + const state: S = + hook !== null + ? hook.memoizedState + : typeof initialState === 'function' + ? (initialState: any)() + : initialState; + hookLog.push({ primitive: 'State', stackError: new Error(), value: state }); + return [state, (action: BasicStateAction) => {}]; +} + +function useReducer( + reducer: (S, A) => S, + initialArg: I, + init?: I => S +): [S, Dispatch] { + const hook = nextHook(); + let state; + if (hook !== null) { + state = hook.memoizedState; + } else { + state = init !== undefined ? init(initialArg) : ((initialArg: any): S); + } + hookLog.push({ + primitive: 'Reducer', + stackError: new Error(), + value: state, + }); + return [state, (action: A) => {}]; +} + +function useRef(initialValue: T): { current: T } { + const hook = nextHook(); + const ref = hook !== null ? hook.memoizedState : { current: initialValue }; + hookLog.push({ + primitive: 'Ref', + stackError: new Error(), + value: ref.current, + }); + return ref; +} + +function useLayoutEffect( + create: () => (() => void) | void, + inputs: Array | void | null +): void { + nextHook(); + hookLog.push({ + primitive: 'LayoutEffect', + stackError: new Error(), + value: create, + }); +} + +function useEffect( + create: () => (() => void) | void, + inputs: Array | void | null +): void { + nextHook(); + hookLog.push({ primitive: 'Effect', stackError: new Error(), value: create }); +} + +function useImperativeHandle( + ref: { current: T | null } | ((inst: T | null) => mixed) | null | void, + create: () => T, + inputs: Array | void | null +): void { + nextHook(); + // We don't actually store the instance anywhere if there is no ref callback + // and if there is a ref callback it might not store it but if it does we + // have no way of knowing where. So let's only enable introspection of the + // ref itself if it is using the object form. + let instance = undefined; + if (ref !== null && typeof ref === 'object') { + instance = ref.current; + } + hookLog.push({ + primitive: 'ImperativeHandle', + stackError: new Error(), + value: instance, + }); +} + +function useDebugValue(value: any, formatterFn: ?(value: any) => any) { + hookLog.push({ + primitive: 'DebugValue', + stackError: new Error(), + value: typeof formatterFn === 'function' ? formatterFn(value) : value, + }); +} + +function useCallback(callback: T, inputs: Array | void | null): T { + const hook = nextHook(); + hookLog.push({ + primitive: 'Callback', + stackError: new Error(), + value: hook !== null ? hook.memoizedState[0] : callback, + }); + return callback; +} + +function useMemo( + nextCreate: () => T, + inputs: Array | void | null +): T { + const hook = nextHook(); + const value = hook !== null ? hook.memoizedState[0] : nextCreate(); + hookLog.push({ primitive: 'Memo', stackError: new Error(), value }); + return value; +} + +function useResponder( + responder: ReactEventResponder, + listenerProps: Object +): ReactEventResponderListener { + // Don't put the actual event responder object in, just its displayName + const value = { + responder: responder.displayName || 'EventResponder', + props: listenerProps, + }; + hookLog.push({ primitive: 'Responder', stackError: new Error(), value }); + return { + responder, + props: listenerProps, + }; +} + +const Dispatcher = { + readContext, + useCallback, + useContext, + useEffect, + useImperativeHandle, + useDebugValue, + useLayoutEffect, + useMemo, + useReducer, + useRef, + useState, + useResponder, +}; + +// Inspect + +type ReactCurrentDispatcher = { + current: null | typeof Dispatcher, +}; + +type HooksNode = { + id: number | null, + isStateEditable: boolean, + name: string, + value: mixed, + subHooks: Array, +}; +type HooksTree = Array; + +// Don't assume +// +// We can't assume that stack frames are nth steps away from anything. +// E.g. we can't assume that the root call shares all frames with the stack +// of a hook call. A simple way to demonstrate this is wrapping `new Error()` +// in a wrapper constructor like a polyfill. That'll add an extra frame. +// Similar things can happen with the call to the dispatcher. The top frame +// may not be the primitive. Likewise the primitive can have fewer stack frames +// such as when a call to useState got inlined to use dispatcher.useState. +// +// We also can't assume that the last frame of the root call is the same +// frame as the last frame of the hook call because long stack traces can be +// truncated to a stack trace limit. + +let mostLikelyAncestorIndex = 0; + +function findSharedIndex(hookStack, rootStack, rootIndex) { + const source = rootStack[rootIndex].source; + hookSearch: for (let i = 0; i < hookStack.length; i++) { + if (hookStack[i].source === source) { + // This looks like a match. Validate that the rest of both stack match up. + for ( + let a = rootIndex + 1, b = i + 1; + a < rootStack.length && b < hookStack.length; + a++, b++ + ) { + if (hookStack[b].source !== rootStack[a].source) { + // If not, give up and try a different match. + continue hookSearch; + } + } + return i; + } + } + return -1; +} + +function findCommonAncestorIndex(rootStack, hookStack) { + let rootIndex = findSharedIndex( + hookStack, + rootStack, + mostLikelyAncestorIndex + ); + if (rootIndex !== -1) { + return rootIndex; + } + // If the most likely one wasn't a hit, try any other frame to see if it is shared. + // If that takes more than 5 frames, something probably went wrong. + for (let i = 0; i < rootStack.length && i < 5; i++) { + rootIndex = findSharedIndex(hookStack, rootStack, i); + if (rootIndex !== -1) { + mostLikelyAncestorIndex = i; + return rootIndex; + } + } + return -1; +} + +function isReactWrapper(functionName, primitiveName) { + if (!functionName) { + return false; + } + const expectedPrimitiveName = 'use' + primitiveName; + if (functionName.length < expectedPrimitiveName.length) { + return false; + } + return ( + functionName.lastIndexOf(expectedPrimitiveName) === + functionName.length - expectedPrimitiveName.length + ); +} + +function findPrimitiveIndex(hookStack, hook) { + const stackCache = getPrimitiveStackCache(); + const primitiveStack = stackCache.get(hook.primitive); + if (primitiveStack === undefined) { + return -1; + } + for (let i = 0; i < primitiveStack.length && i < hookStack.length; i++) { + if (primitiveStack[i].source !== hookStack[i].source) { + // If the next two frames are functions called `useX` then we assume that they're part of the + // wrappers that the React packager or other packages adds around the dispatcher. + if ( + i < hookStack.length - 1 && + isReactWrapper(hookStack[i].functionName, hook.primitive) + ) { + i++; + } + if ( + i < hookStack.length - 1 && + isReactWrapper(hookStack[i].functionName, hook.primitive) + ) { + i++; + } + return i; + } + } + return -1; +} + +function parseTrimmedStack(rootStack, hook) { + // Get the stack trace between the primitive hook function and + // the root function call. I.e. the stack frames of custom hooks. + const hookStack = ErrorStackParser.parse(hook.stackError); + const rootIndex = findCommonAncestorIndex(rootStack, hookStack); + const primitiveIndex = findPrimitiveIndex(hookStack, hook); + if ( + rootIndex === -1 || + primitiveIndex === -1 || + rootIndex - primitiveIndex < 2 + ) { + // Something went wrong. Give up. + return null; + } + return hookStack.slice(primitiveIndex, rootIndex - 1); +} + +function parseCustomHookName(functionName: void | string): string { + if (!functionName) { + return ''; + } + let startIndex = functionName.lastIndexOf('.'); + if (startIndex === -1) { + startIndex = 0; + } + if (functionName.substr(startIndex, 3) === 'use') { + startIndex += 3; + } + return functionName.substr(startIndex); +} + +function buildTree(rootStack, readHookLog): HooksTree { + const rootChildren = []; + let prevStack = null; + let levelChildren = rootChildren; + let nativeHookID = 0; + const stackOfChildren = []; + for (let i = 0; i < readHookLog.length; i++) { + const hook = readHookLog[i]; + const stack = parseTrimmedStack(rootStack, hook); + if (stack !== null) { + // Note: The indices 0 <= n < length-1 will contain the names. + // The indices 1 <= n < length will contain the source locations. + // That's why we get the name from n - 1 and don't check the source + // of index 0. + let commonSteps = 0; + if (prevStack !== null) { + // Compare the current level's stack to the new stack. + while (commonSteps < stack.length && commonSteps < prevStack.length) { + const stackSource = stack[stack.length - commonSteps - 1].source; + const prevSource = + prevStack[prevStack.length - commonSteps - 1].source; + if (stackSource !== prevSource) { + break; + } + commonSteps++; + } + // Pop back the stack as many steps as were not common. + for (let j = prevStack.length - 1; j > commonSteps; j--) { + levelChildren = stackOfChildren.pop(); + } + } + // The remaining part of the new stack are custom hooks. Push them + // to the tree. + for (let j = stack.length - commonSteps - 1; j >= 1; j--) { + const children = []; + levelChildren.push({ + id: null, + isStateEditable: false, + name: parseCustomHookName(stack[j - 1].functionName), + value: undefined, + subHooks: children, + }); + stackOfChildren.push(levelChildren); + levelChildren = children; + } + prevStack = stack; + } + const { primitive } = hook; + + // For now, the "id" of stateful hooks is just the stateful hook index. + // Custom hooks have no ids, nor do non-stateful native hooks (e.g. Context, DebugValue). + const id = + primitive === 'Context' || primitive === 'DebugValue' + ? null + : nativeHookID++; + + // For the time being, only State and Reducer hooks support runtime overrides. + const isStateEditable = primitive === 'Reducer' || primitive === 'State'; + + levelChildren.push({ + id, + isStateEditable, + name: primitive, + value: hook.value, + subHooks: [], + }); + } + + // Associate custom hook values (useDebugValue() hook entries) with the correct hooks. + processDebugValues(rootChildren, null); + + return rootChildren; +} + +// Custom hooks support user-configurable labels (via the special useDebugValue() hook). +// That hook adds user-provided values to the hooks tree, +// but these values aren't intended to appear alongside of the other hooks. +// Instead they should be attributed to their parent custom hook. +// This method walks the tree and assigns debug values to their custom hook owners. +function processDebugValues( + hooksTree: HooksTree, + parentHooksNode: HooksNode | null +): void { + const debugValueHooksNodes: Array = []; + + for (let i = 0; i < hooksTree.length; i++) { + const hooksNode = hooksTree[i]; + if (hooksNode.name === 'DebugValue' && hooksNode.subHooks.length === 0) { + hooksTree.splice(i, 1); + i--; + debugValueHooksNodes.push(hooksNode); + } else { + processDebugValues(hooksNode.subHooks, hooksNode); + } + } + + // Bubble debug value labels to their custom hook owner. + // If there is no parent hook, just ignore them for now. + // (We may warn about this in the future.) + if (parentHooksNode !== null) { + if (debugValueHooksNodes.length === 1) { + parentHooksNode.value = debugValueHooksNodes[0].value; + } else if (debugValueHooksNodes.length > 1) { + parentHooksNode.value = debugValueHooksNodes.map(({ value }) => value); + } + } +} + +export function inspectHooks( + renderFunction: Props => React$Node, + props: Props, + currentDispatcher: ReactCurrentDispatcher +): HooksTree { + // DevTools will pass the current renderer's injected dispatcher. + // Other apps might compile debug hooks as part of their app though. + //if (currentDispatcher == null) { + //currentDispatcher = ReactSharedInternals.ReactCurrentDispatcher; + //} + + const previousDispatcher = currentDispatcher.current; + let readHookLog; + currentDispatcher.current = Dispatcher; + let ancestorStackError; + try { + ancestorStackError = new Error(); + renderFunction(props); + } finally { + readHookLog = hookLog; + hookLog = []; + currentDispatcher.current = previousDispatcher; + } + const rootStack = ErrorStackParser.parse(ancestorStackError); + return buildTree(rootStack, readHookLog); +} + +function setupContexts(contextMap: Map, fiber: Fiber) { + let current = fiber; + while (current) { + if (current.tag === ContextProvider) { + const providerType: any = current.type; + const context: any = providerType._context; + if (!contextMap.has(context)) { + // Store the current value that we're going to restore later. + contextMap.set(context, context._currentValue); + // Set the inner most provider value on the context. + context._currentValue = current.memoizedProps.value; + } + } + current = current.return; + } +} + +function restoreContexts(contextMap: Map) { + contextMap.forEach((value, context) => (context._currentValue = value)); +} + +function inspectHooksOfForwardRef( + renderFunction: (Props, Ref) => React$Node, + props: Props, + ref: Ref, + currentDispatcher: ReactCurrentDispatcher +): HooksTree { + const previousDispatcher = currentDispatcher.current; + let readHookLog; + currentDispatcher.current = Dispatcher; + let ancestorStackError; + try { + ancestorStackError = new Error(); + renderFunction(props, ref); + } finally { + readHookLog = hookLog; + hookLog = []; + currentDispatcher.current = previousDispatcher; + } + const rootStack = ErrorStackParser.parse(ancestorStackError); + return buildTree(rootStack, readHookLog); +} + +function resolveDefaultProps(Component, baseProps) { + if (Component && Component.defaultProps) { + // Resolve default props. Taken from ReactElement + const props = Object.assign({}, baseProps); + const defaultProps = Component.defaultProps; + for (const propName in defaultProps) { + if (props[propName] === undefined) { + props[propName] = defaultProps[propName]; + } + } + return props; + } + return baseProps; +} + +export function inspectHooksOfFiber( + fiber: Fiber, + currentDispatcher: ReactCurrentDispatcher +) { + // DevTools will pass the current renderer's injected dispatcher. + // Other apps might compile debug hooks as part of their app though. + //if (currentDispatcher == null) { + //currentDispatcher = ReactSharedInternals.ReactCurrentDispatcher; + //} + + if ( + fiber.tag !== FunctionComponent && + fiber.tag !== SimpleMemoComponent && + fiber.tag !== ForwardRef + ) { + throw new Error( + 'Unknown Fiber. Needs to be a function component to inspect hooks.' + ); + } + // Warm up the cache so that it doesn't consume the currentHook. + getPrimitiveStackCache(); + const type = fiber.type; + let props = fiber.memoizedProps; + if (type !== fiber.elementType) { + props = resolveDefaultProps(type, props); + } + // Set up the current hook so that we can step through and read the + // current state from them. + currentHook = (fiber.memoizedState: Hook); + const contextMap = new Map(); + try { + setupContexts(contextMap, fiber); + if (fiber.tag === ForwardRef) { + return inspectHooksOfForwardRef( + type.render, + props, + fiber.ref, + currentDispatcher + ); + } + return inspectHooks(type, props, currentDispatcher); + } finally { + currentHook = null; + restoreContexts(contextMap); + } +} diff --git a/extension/src/backend/agent.js b/extension/src/backend/agent.js new file mode 100644 index 0000000000000..f246934a5d3d9 --- /dev/null +++ b/extension/src/backend/agent.js @@ -0,0 +1,485 @@ +// @flow + +import EventEmitter from 'events'; +import throttle from 'lodash.throttle'; +import { + SESSION_STORAGE_LAST_SELECTION_KEY, + SESSION_STORAGE_RELOAD_AND_PROFILE_KEY, + SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY, + __DEBUG__, +} from '../constants'; +import { + sessionStorageGetItem, + sessionStorageRemoveItem, + sessionStorageSetItem, +} from 'src/storage'; +import setupHighlighter from './views/Highlighter'; +import { patch as patchConsole, unpatch as unpatchConsole } from './console'; + +import type { BackendBridge } from 'src/bridge'; +import type { + InstanceAndStyle, + NativeType, + OwnersList, + PathFrame, + PathMatch, + RendererID, + RendererInterface, +} from './types'; +import type { ComponentFilter } from '../types'; + +const debug = (methodName, ...args) => { + if (__DEBUG__) { + console.log( + `%cAgent %c${methodName}`, + 'color: purple; font-weight: bold;', + 'font-weight: bold;', + ...args + ); + } +}; + +type ElementAndRendererID = {| + id: number, + rendererID: number, +|}; + +type InspectElementParams = {| + id: number, + path?: Array, + rendererID: number, +|}; + +type OverrideHookParams = {| + id: number, + hookID: number, + path: Array, + rendererID: number, + value: any, +|}; + +type SetInParams = {| + id: number, + path: Array, + rendererID: number, + value: any, +|}; + +type OverrideSuspenseParams = {| + id: number, + rendererID: number, + forceFallback: boolean, +|}; + +type PersistedSelection = {| + rendererID: number, + path: Array, +|}; + +export default class Agent extends EventEmitter<{| + hideNativeHighlight: [], + showNativeHighlight: [NativeType], + shutdown: [], +|}> { + _bridge: BackendBridge; + _isProfiling: boolean = false; + _recordChangeDescriptions: boolean = false; + _rendererInterfaces: { [key: RendererID]: RendererInterface } = {}; + _persistedSelection: PersistedSelection | null = null; + _persistedSelectionMatch: PathMatch | null = null; + + constructor(bridge: BackendBridge) { + super(); + + if ( + sessionStorageGetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY) === 'true' + ) { + this._recordChangeDescriptions = + sessionStorageGetItem( + SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY + ) === 'true'; + this._isProfiling = true; + + sessionStorageRemoveItem(SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY); + sessionStorageRemoveItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY); + } + + const persistedSelectionString = sessionStorageGetItem( + SESSION_STORAGE_LAST_SELECTION_KEY + ); + if (persistedSelectionString != null) { + this._persistedSelection = JSON.parse(persistedSelectionString); + } + + this._bridge = bridge; + + bridge.addListener('getProfilingData', this.getProfilingData); + bridge.addListener('getProfilingStatus', this.getProfilingStatus); + bridge.addListener('getOwnersList', this.getOwnersList); + bridge.addListener('inspectElement', this.inspectElement); + bridge.addListener('logElementToConsole', this.logElementToConsole); + bridge.addListener('overrideContext', this.overrideContext); + bridge.addListener('overrideHookState', this.overrideHookState); + bridge.addListener('overrideProps', this.overrideProps); + bridge.addListener('overrideState', this.overrideState); + bridge.addListener('overrideSuspense', this.overrideSuspense); + bridge.addListener('reloadAndProfile', this.reloadAndProfile); + bridge.addListener('startProfiling', this.startProfiling); + bridge.addListener('stopProfiling', this.stopProfiling); + bridge.addListener( + 'syncSelectionFromNativeElementsPanel', + this.syncSelectionFromNativeElementsPanel + ); + bridge.addListener('shutdown', this.shutdown); + bridge.addListener( + 'updateAppendComponentStack', + this.updateAppendComponentStack + ); + bridge.addListener('updateComponentFilters', this.updateComponentFilters); + bridge.addListener('viewElementSource', this.viewElementSource); + + if (this._isProfiling) { + bridge.send('profilingStatus', true); + } + + // Notify the frontend if the backend supports the Storage API (e.g. localStorage). + // If not, features like reload-and-profile will not work correctly and must be disabled. + let isBackendStorageAPISupported = false; + try { + localStorage.getItem('test'); + isBackendStorageAPISupported = true; + } catch (error) {} + bridge.send('isBackendStorageAPISupported', isBackendStorageAPISupported); + + setupHighlighter(bridge, this); + } + + get rendererInterfaces(): { [key: RendererID]: RendererInterface } { + return this._rendererInterfaces; + } + + getInstanceAndStyle({ + id, + rendererID, + }: ElementAndRendererID): InstanceAndStyle | null { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}"`); + return null; + } + return renderer.getInstanceAndStyle(id); + } + + getIDForNode(node: Object): number | null { + for (let rendererID in this._rendererInterfaces) { + const renderer = ((this._rendererInterfaces[ + (rendererID: any) + ]: any): RendererInterface); + + try { + const id = renderer.getFiberIDForNative(node, true); + if (id !== null) { + return id; + } + } catch (error) { + // Some old React versions might throw if they can't find a match. + // If so we should ignore it... + } + } + return null; + } + + getProfilingData = ({ rendererID }: {| rendererID: RendererID |}) => { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}"`); + } + + this._bridge.send('profilingData', renderer.getProfilingData()); + }; + + getProfilingStatus = () => { + this._bridge.send('profilingStatus', this._isProfiling); + }; + + getOwnersList = ({ id, rendererID }: ElementAndRendererID) => { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + } else { + const owners = renderer.getOwnersList(id); + this._bridge.send('ownersList', ({ id, owners }: OwnersList)); + } + }; + + inspectElement = ({ id, path, rendererID }: 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)); + + // When user selects an element, stop trying to restore the selection, + // and instead remember the current selection for the next reload. + if ( + this._persistedSelectionMatch === null || + this._persistedSelectionMatch.id !== id + ) { + this._persistedSelection = null; + this._persistedSelectionMatch = null; + renderer.setTrackedPath(null); + this._throttledPersistSelection(rendererID, id); + } + + // TODO: If there was a way to change the selected DOM element + // in native Elements tab without forcing a switch to it, we'd do it here. + // For now, it doesn't seem like there is a way to do that: + // https://github.com/bvaughn/react-devtools-experimental/issues/102 + // (Setting $0 doesn't work, and calling inspect() switches the tab.) + } + }; + + logElementToConsole = ({ id, rendererID }: ElementAndRendererID) => { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + } else { + renderer.logElementToConsole(id); + } + }; + + reloadAndProfile = (recordChangeDescriptions: boolean) => { + sessionStorageSetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY, 'true'); + sessionStorageSetItem( + SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY, + recordChangeDescriptions ? 'true' : 'false' + ); + + // This code path should only be hit if the shell has explicitly told the Store that it supports profiling. + // In that case, the shell must also listen for this specific message to know when it needs to reload the app. + // The agent can't do this in a way that is renderer agnostic. + this._bridge.send('reloadAppForProfiling'); + }; + + overrideContext = ({ id, path, rendererID, value }: SetInParams) => { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + } else { + renderer.setInContext(id, path, value); + } + }; + + overrideHookState = ({ + id, + hookID, + path, + rendererID, + value, + }: OverrideHookParams) => { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + } else { + renderer.setInHook(id, hookID, path, value); + } + }; + + overrideProps = ({ id, path, rendererID, value }: SetInParams) => { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + } else { + renderer.setInProps(id, path, value); + } + }; + + overrideState = ({ id, path, rendererID, value }: SetInParams) => { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + } else { + renderer.setInState(id, path, value); + } + }; + + overrideSuspense = ({ + id, + rendererID, + forceFallback, + }: OverrideSuspenseParams) => { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + } else { + renderer.overrideSuspense(id, forceFallback); + } + }; + + selectNode(target: Object): void { + const id = this.getIDForNode(target); + if (id !== null) { + this._bridge.send('selectFiber', id); + } + } + + setRendererInterface( + rendererID: RendererID, + rendererInterface: RendererInterface + ) { + this._rendererInterfaces[rendererID] = rendererInterface; + + if (this._isProfiling) { + rendererInterface.startProfiling(this._recordChangeDescriptions); + } + + // When the renderer is attached, we need to tell it whether + // we remember the previous selection that we'd like to restore. + // It'll start tracking mounts for matches to the last selection path. + const selection = this._persistedSelection; + if (selection !== null && selection.rendererID === rendererID) { + rendererInterface.setTrackedPath(selection.path); + } + } + + syncSelectionFromNativeElementsPanel = () => { + const target = window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0; + if (target == null) { + return; + } + this.selectNode(target); + }; + + shutdown = () => { + // Clean up the overlay if visible, and associated events. + this.emit('shutdown'); + }; + + startProfiling = (recordChangeDescriptions: boolean) => { + this._recordChangeDescriptions = recordChangeDescriptions; + this._isProfiling = true; + for (let rendererID in this._rendererInterfaces) { + const renderer = ((this._rendererInterfaces[ + (rendererID: any) + ]: any): RendererInterface); + renderer.startProfiling(recordChangeDescriptions); + } + this._bridge.send('profilingStatus', this._isProfiling); + }; + + stopProfiling = () => { + this._isProfiling = false; + this._recordChangeDescriptions = false; + for (let rendererID in this._rendererInterfaces) { + const renderer = ((this._rendererInterfaces[ + (rendererID: any) + ]: any): RendererInterface); + renderer.stopProfiling(); + } + this._bridge.send('profilingStatus', this._isProfiling); + }; + + updateAppendComponentStack = (appendComponentStack: boolean) => { + // If the frontend preference has change, + // or in the case of React Native- if the backend is just finding out the preference- + // then install or uninstall the console overrides. + // It's safe to call these methods multiple times, so we don't need to worry about that. + if (appendComponentStack) { + patchConsole(); + } else { + unpatchConsole(); + } + }; + + updateComponentFilters = (componentFilters: Array) => { + for (let rendererID in this._rendererInterfaces) { + const renderer = ((this._rendererInterfaces[ + (rendererID: any) + ]: any): RendererInterface); + renderer.updateComponentFilters(componentFilters); + } + }; + + viewElementSource = ({ id, rendererID }: ElementAndRendererID) => { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + } else { + renderer.prepareViewElementSource(id); + } + }; + + onHookOperations = (operations: Array) => { + if (__DEBUG__) { + debug('onHookOperations', operations); + } + + // TODO: + // The chrome.runtime does not currently support transferables; it forces JSON serialization. + // See bug https://bugs.chromium.org/p/chromium/issues/detail?id=927134 + // + // Regarding transferables, the postMessage doc states: + // If the ownership of an object is transferred, it becomes unusable (neutered) + // in the context it was sent from and becomes available only to the worker it was sent to. + // + // Even though Chrome is eventually JSON serializing the array buffer, + // using the transferable approach also sometimes causes it to throw: + // DOMException: Failed to execute 'postMessage' on 'Window': ArrayBuffer at index 0 is already neutered. + // + // See bug https://github.com/bvaughn/react-devtools-experimental/issues/25 + // + // The Store has a fallback in place that parses the message as JSON if the type isn't an array. + // For now the simplest fix seems to be to not transfer the array. + // This will negatively impact performance on Firefox so it's unfortunate, + // but until we're able to fix the Chrome error mentioned above, it seems necessary. + // + // this._bridge.send('operations', operations, [operations.buffer]); + this._bridge.send('operations', operations); + + if (this._persistedSelection !== null) { + const rendererID = operations[0]; + if (this._persistedSelection.rendererID === rendererID) { + // Check if we can select a deeper match for the persisted selection. + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}"`); + } else { + const prevMatch = this._persistedSelectionMatch; + const nextMatch = renderer.getBestMatchForTrackedPath(); + this._persistedSelectionMatch = nextMatch; + const prevMatchID = prevMatch !== null ? prevMatch.id : null; + const nextMatchID = nextMatch !== null ? nextMatch.id : null; + if (prevMatchID !== nextMatchID) { + if (nextMatchID !== null) { + // We moved forward, unlocking a deeper node. + this._bridge.send('selectFiber', nextMatchID); + } + } + if (nextMatch !== null && nextMatch.isFullMatch) { + // We've just unlocked the innermost selected node. + // There's no point tracking it further. + this._persistedSelection = null; + this._persistedSelectionMatch = null; + renderer.setTrackedPath(null); + } + } + } + } + }; + + _throttledPersistSelection = throttle((rendererID: number, id: number) => { + // This is throttled, so both renderer and selected ID + // might not be available by the time we read them. + // This is why we need the defensive checks here. + const renderer = this._rendererInterfaces[rendererID]; + const path = renderer != null ? renderer.getPathForElement(id) : null; + if (path !== null) { + sessionStorageSetItem( + SESSION_STORAGE_LAST_SELECTION_KEY, + JSON.stringify(({ rendererID, path }: PersistedSelection)) + ); + } else { + sessionStorageRemoveItem(SESSION_STORAGE_LAST_SELECTION_KEY); + } + }, 1000); +} diff --git a/extension/src/backend/console.js b/extension/src/backend/console.js new file mode 100644 index 0000000000000..22f5db951541c --- /dev/null +++ b/extension/src/backend/console.js @@ -0,0 +1,143 @@ +// @flow + +import { getInternalReactConstants } from './renderer'; +import describeComponentFrame from './describeComponentFrame'; + +import type { Fiber, ReactRenderer } from './types'; + +const APPEND_STACK_TO_METHODS = ['error', 'trace', 'warn']; + +const FRAME_REGEX = /\n {4}in /; + +const injectedRenderers: Map< + ReactRenderer, + {| + getCurrentFiber: () => Fiber | null, + getDisplayNameForFiber: (fiber: Fiber) => string | null, + |} +> = new Map(); + +let targetConsole: Object = console; +let targetConsoleMethods = {}; +for (let method in console) { + targetConsoleMethods[method] = console[method]; +} + +let unpatchFn: null | (() => void) = null; + +// Enables e.g. Jest tests to inject a mock console object. +export function dangerous_setTargetConsoleForTesting( + targetConsoleForTesting: Object +): void { + targetConsole = targetConsoleForTesting; + + targetConsoleMethods = {}; + for (let method in targetConsole) { + targetConsoleMethods[method] = console[method]; + } +} + +// v16 renderers should use this method to inject internals necessary to generate a component stack. +// These internals will be used if the console is patched. +// Injecting them separately allows the console to easily be patched or unpacted later (at runtime). +export function registerRenderer(renderer: ReactRenderer): void { + const { getCurrentFiber, findFiberByHostInstance, version } = renderer; + + // Ignore React v15 and older because they don't expose a component stack anyway. + if (typeof findFiberByHostInstance !== 'function') { + return; + } + + if (typeof getCurrentFiber === 'function') { + const { getDisplayNameForFiber } = getInternalReactConstants(version); + + injectedRenderers.set(renderer, { + getCurrentFiber, + getDisplayNameForFiber, + }); + } +} + +// Patches whitelisted console methods to append component stack for the current fiber. +// Call unpatch() to remove the injected behavior. +export function patch(): void { + if (unpatchFn !== null) { + // Don't patch twice. + return; + } + + const originalConsoleMethods = {}; + + unpatchFn = () => { + for (let method in originalConsoleMethods) { + try { + // $FlowFixMe property error|warn is not writable. + targetConsole[method] = originalConsoleMethods[method]; + } catch (error) {} + } + }; + + APPEND_STACK_TO_METHODS.forEach(method => { + try { + const originalMethod = (originalConsoleMethods[method] = + targetConsole[method]); + + const overrideMethod = (...args) => { + try { + // If we are ever called with a string that already has a component stack, e.g. a React error/warning, + // don't append a second stack. + const alreadyHasComponentStack = + args.length > 0 && FRAME_REGEX.exec(args[args.length - 1]); + + if (!alreadyHasComponentStack) { + // If there's a component stack for at least one of the injected renderers, append it. + // We don't handle the edge case of stacks for more than one (e.g. interleaved renderers?) + for (let { + getCurrentFiber, + getDisplayNameForFiber, + } of injectedRenderers.values()) { + let current: ?Fiber = getCurrentFiber(); + let ownerStack: string = ''; + while (current != null) { + const name = getDisplayNameForFiber(current); + const owner = current._debugOwner; + const ownerName = + owner != null ? getDisplayNameForFiber(owner) : null; + + ownerStack += describeComponentFrame( + name, + current._debugSource, + ownerName + ); + + current = owner; + } + + if (ownerStack !== '') { + args.push(ownerStack); + break; + } + } + } + } catch (error) { + // Don't let a DevTools or React internal error interfere with logging. + } + + originalMethod(...args); + }; + + overrideMethod.__REACT_DEVTOOLS_ORIGINAL_METHOD__ = originalMethod; + + // $FlowFixMe property error|warn is not writable. + targetConsole[method] = overrideMethod; + } catch (error) {} + }); +} + +// Removed component stack patch from whitelisted console methods. +export function unpatch(): void { + if (unpatchFn !== null) { + unpatchFn(); + unpatchFn = null; + } +} diff --git a/extension/src/backend/describeComponentFrame.js b/extension/src/backend/describeComponentFrame.js new file mode 100644 index 0000000000000..5c632471babc1 --- /dev/null +++ b/extension/src/backend/describeComponentFrame.js @@ -0,0 +1,41 @@ +// @flow + +// This file was forked from the React GitHub repo: +// https://raw.githubusercontent.com/facebook/react/master/packages/shared/describeComponentFrame.js +// +// It has been modified sligthly to add a zero width space as commented below. + +const BEFORE_SLASH_RE = /^(.*)[\\/]/; + +export default function describeComponentFrame( + name: null | string, + source: any, + ownerName: null | string +) { + let sourceInfo = ''; + if (source) { + let path = source.fileName; + let fileName = path.replace(BEFORE_SLASH_RE, ''); + if (__DEV__) { + // In DEV, include code for a common special case: + // prefer "folder/index.js" instead of just "index.js". + if (/^index\./.test(fileName)) { + const match = path.match(BEFORE_SLASH_RE); + if (match) { + const pathBeforeSlash = match[1]; + if (pathBeforeSlash) { + const folderName = pathBeforeSlash.replace(BEFORE_SLASH_RE, ''); + // Note the below string contains a zero width space after the "/" character. + // This is to prevent browsers like Chrome from formatting the file name as a link. + // (Since this is a source link, it would not work to open the source file anyway.) + fileName = folderName + '/​' + fileName; + } + } + } + } + sourceInfo = ' (at ' + fileName + ':' + source.lineNumber + ')'; + } else if (ownerName) { + sourceInfo = ' (created by ' + ownerName + ')'; + } + return '\n in ' + (name || 'Unknown') + sourceInfo; +} diff --git a/extension/src/backend/index.js b/extension/src/backend/index.js new file mode 100644 index 0000000000000..feee4c00dedd8 --- /dev/null +++ b/extension/src/backend/index.js @@ -0,0 +1,95 @@ +// @flow + +import Agent from './agent'; + +import { attach } from './renderer'; +import { attach as attachLegacy } from './legacy/renderer'; + +import type { DevToolsHook, ReactRenderer, RendererInterface } from './types'; + +export function initBackend( + hook: DevToolsHook, + agent: Agent, + global: Object +): () => void { + const subs = [ + hook.sub( + 'renderer-attached', + ({ + id, + renderer, + rendererInterface, + }: { + id: number, + renderer: ReactRenderer, + rendererInterface: RendererInterface, + }) => { + agent.setRendererInterface(id, rendererInterface); + + // Now that the Store and the renderer interface are connected, + // it's time to flush the pending operation codes to the frontend. + rendererInterface.flushInitialOperations(); + } + ), + + hook.sub('operations', agent.onHookOperations), + + // TODO Add additional subscriptions required for profiling mode + ]; + + const attachRenderer = (id: number, renderer: ReactRenderer) => { + let rendererInterface = hook.rendererInterfaces.get(id); + + // Inject any not-yet-injected renderers (if we didn't reload-and-profile) + if (!rendererInterface) { + if (typeof renderer.findFiberByHostInstance === 'function') { + rendererInterface = attach(hook, id, renderer, global); + } else { + rendererInterface = attachLegacy(hook, id, renderer, global); + } + + hook.rendererInterfaces.set(id, rendererInterface); + } + + // Notify the DevTools frontend about new renderers. + // This includes any that were attached early (via __REACT_DEVTOOLS_ATTACH__). + hook.emit('renderer-attached', { + id, + renderer, + rendererInterface, + }); + }; + + // Connect renderers that have already injected themselves. + hook.renderers.forEach((renderer, id) => { + attachRenderer(id, renderer); + }); + + // Connect any new renderers that injected themselves. + subs.push( + hook.sub( + 'renderer', + ({ id, renderer }: { id: number, renderer: ReactRenderer }) => { + attachRenderer(id, renderer); + } + ) + ); + + hook.emit('react-devtools', agent); + hook.reactDevtoolsAgent = agent; + const onAgentShutdown = () => { + subs.forEach(fn => fn()); + hook.rendererInterfaces.forEach(rendererInterface => { + rendererInterface.cleanup(); + }); + hook.reactDevtoolsAgent = null; + }; + agent.addListener('shutdown', onAgentShutdown); + subs.push(() => { + agent.removeListener('shutdown', onAgentShutdown); + }); + + return () => { + subs.forEach(fn => fn()); + }; +} diff --git a/extension/src/backend/legacy/renderer.js b/extension/src/backend/legacy/renderer.js new file mode 100644 index 0000000000000..51ca6a626ac97 --- /dev/null +++ b/extension/src/backend/legacy/renderer.js @@ -0,0 +1,936 @@ +// @flow + +import { + ElementTypeClass, + ElementTypeFunction, + ElementTypeRoot, + ElementTypeHostComponent, + ElementTypeOtherOrUnknown, +} from 'src/types'; +import { getUID, utfEncodeString, printOperationsArray } from '../../utils'; +import { cleanForBridge, copyWithSet } from '../utils'; +import { getDisplayName } from 'src/utils'; +import { + __DEBUG__, + TREE_OPERATION_ADD, + TREE_OPERATION_REMOVE, + TREE_OPERATION_REORDER_CHILDREN, +} from '../../constants'; +import { decorateMany, forceUpdate, restoreMany } from './utils'; + +import type { + DevToolsHook, + GetFiberIDForNative, + InspectedElementPayload, + InstanceAndStyle, + NativeType, + PathFrame, + PathMatch, + RendererInterface, +} from '../types'; +import type { ComponentFilter, ElementType } from 'src/types'; +import type { Owner, InspectedElement } from '../types'; + +export type InternalInstance = Object; +type LegacyRenderer = Object; + +function getData(internalInstance: InternalInstance) { + let displayName = null; + let key = null; + + // != used deliberately here to catch undefined and null + if (internalInstance._currentElement != null) { + if (internalInstance._currentElement.key) { + key = String(internalInstance._currentElement.key); + } + + const elementType = internalInstance._currentElement.type; + if (typeof elementType === 'string') { + displayName = elementType; + } else if (typeof elementType === 'function') { + displayName = getDisplayName(elementType); + } + } + + return { + displayName, + key, + }; +} + +function getElementType(internalInstance: InternalInstance): ElementType { + // != used deliberately here to catch undefined and null + if (internalInstance._currentElement != null) { + const elementType = internalInstance._currentElement.type; + if (typeof elementType === 'function') { + const publicInstance = internalInstance.getPublicInstance(); + if (publicInstance !== null) { + return ElementTypeClass; + } else { + return ElementTypeFunction; + } + } else if (typeof elementType === 'string') { + return ElementTypeHostComponent; + } + } + return ElementTypeOtherOrUnknown; +} + +function getChildren(internalInstance: Object): Array { + let children = []; + + // If the parent is a native node without rendered children, but with + // multiple string children, then the `element` that gets passed in here is + // a plain value -- a string or number. + if (typeof internalInstance !== 'object') { + // No children + } else if ( + internalInstance._currentElement === null || + internalInstance._currentElement === false + ) { + // No children + } else if (internalInstance._renderedComponent) { + const child = internalInstance._renderedComponent; + if (getElementType(child) !== ElementTypeOtherOrUnknown) { + children.push(child); + } + } else if (internalInstance._renderedChildren) { + const renderedChildren = internalInstance._renderedChildren; + for (let name in renderedChildren) { + const child = renderedChildren[name]; + if (getElementType(child) !== ElementTypeOtherOrUnknown) { + children.push(child); + } + } + } + // Note: we skip the case where children are just strings or numbers + // because the new DevTools skips over host text nodes anyway. + return children; +} + +export function attach( + hook: DevToolsHook, + rendererID: number, + renderer: LegacyRenderer, + global: Object +): RendererInterface { + const idToInternalInstanceMap: Map = new Map(); + const internalInstanceToIDMap: WeakMap< + InternalInstance, + number + > = new WeakMap(); + const internalInstanceToRootIDMap: WeakMap< + InternalInstance, + number + > = new WeakMap(); + + let getInternalIDForNative: GetFiberIDForNative = ((null: any): GetFiberIDForNative); + let findNativeNodeForInternalID: (id: number) => ?NativeType; + + if (renderer.ComponentTree) { + getInternalIDForNative = (node, findNearestUnfilteredAncestor) => { + const internalInstance = renderer.ComponentTree.getClosestInstanceFromNode( + node + ); + return internalInstanceToIDMap.get(internalInstance) || null; + }; + findNativeNodeForInternalID = (id: number) => { + const internalInstance = idToInternalInstanceMap.get(id); + return renderer.ComponentTree.getNodeFromInstance(internalInstance); + }; + } else if (renderer.Mount.getID && renderer.Mount.getNode) { + getInternalIDForNative = (node, findNearestUnfilteredAncestor) => { + // Not implemented. + return null; + }; + findNativeNodeForInternalID = (id: number) => { + // Not implemented. + return null; + }; + } + + function getID(internalInstance: InternalInstance): number { + if (typeof internalInstance !== 'object') { + throw new Error('Invalid internal instance: ' + internalInstance); + } + if (!internalInstanceToIDMap.has(internalInstance)) { + const id = getUID(); + internalInstanceToIDMap.set(internalInstance, id); + idToInternalInstanceMap.set(id, internalInstance); + } + return ((internalInstanceToIDMap.get(internalInstance): any): number); + } + + function areEqualArrays(a, b) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; + } + + // This is shared mutable state that lets us keep track of where we are. + let parentIDStack = []; + + let oldReconcilerMethods = null; + if (renderer.Reconciler) { + // React 15 + oldReconcilerMethods = decorateMany(renderer.Reconciler, { + mountComponent(fn, args) { + const internalInstance = args[0]; + const hostContainerInfo = args[3]; + if (getElementType(internalInstance) === ElementTypeOtherOrUnknown) { + return fn.apply(this, args); + } + if (hostContainerInfo._topLevelWrapper === undefined) { + // SSR + return fn.apply(this, args); + } + + const id = getID(internalInstance); + // Push the operation. + const parentID = + parentIDStack.length > 0 + ? parentIDStack[parentIDStack.length - 1] + : 0; + recordMount(internalInstance, id, parentID); + parentIDStack.push(id); + + // Remember the root. + internalInstanceToRootIDMap.set( + internalInstance, + getID(hostContainerInfo._topLevelWrapper) + ); + + try { + const result = fn.apply(this, args); + parentIDStack.pop(); + return result; + } catch (err) { + parentIDStack = []; + throw err; + } finally { + if (parentIDStack.length === 0) { + const rootID = internalInstanceToRootIDMap.get(internalInstance); + if (rootID === undefined) { + throw new Error('Expected to find root ID.'); + } + flushPendingEvents(rootID); + } + } + }, + performUpdateIfNecessary(fn, args) { + const internalInstance = args[0]; + if (getElementType(internalInstance) === ElementTypeOtherOrUnknown) { + return fn.apply(this, args); + } + + const id = getID(internalInstance); + parentIDStack.push(id); + + const prevChildren = getChildren(internalInstance); + try { + const result = fn.apply(this, args); + + const nextChildren = getChildren(internalInstance); + if (!areEqualArrays(prevChildren, nextChildren)) { + // Push the operation + recordReorder(internalInstance, id, nextChildren); + } + + parentIDStack.pop(); + return result; + } catch (err) { + parentIDStack = []; + throw err; + } finally { + if (parentIDStack.length === 0) { + const rootID = internalInstanceToRootIDMap.get(internalInstance); + if (rootID === undefined) { + throw new Error('Expected to find root ID.'); + } + flushPendingEvents(rootID); + } + } + }, + receiveComponent(fn, args) { + const internalInstance = args[0]; + if (getElementType(internalInstance) === ElementTypeOtherOrUnknown) { + return fn.apply(this, args); + } + + const id = getID(internalInstance); + parentIDStack.push(id); + + const prevChildren = getChildren(internalInstance); + try { + const result = fn.apply(this, args); + + const nextChildren = getChildren(internalInstance); + if (!areEqualArrays(prevChildren, nextChildren)) { + // Push the operation + recordReorder(internalInstance, id, nextChildren); + } + + parentIDStack.pop(); + return result; + } catch (err) { + parentIDStack = []; + throw err; + } finally { + if (parentIDStack.length === 0) { + const rootID = internalInstanceToRootIDMap.get(internalInstance); + if (rootID === undefined) { + throw new Error('Expected to find root ID.'); + } + flushPendingEvents(rootID); + } + } + }, + unmountComponent(fn, args) { + const internalInstance = args[0]; + if (getElementType(internalInstance) === ElementTypeOtherOrUnknown) { + return fn.apply(this, args); + } + + const id = getID(internalInstance); + parentIDStack.push(id); + try { + const result = fn.apply(this, args); + parentIDStack.pop(); + + // Push the operation. + recordUnmount(internalInstance, id); + + return result; + } catch (err) { + parentIDStack = []; + throw err; + } finally { + if (parentIDStack.length === 0) { + const rootID = internalInstanceToRootIDMap.get(internalInstance); + if (rootID === undefined) { + throw new Error('Expected to find root ID.'); + } + flushPendingEvents(rootID); + } + } + }, + }); + } + + function cleanup() { + if (oldReconcilerMethods !== null) { + if (renderer.Component) { + restoreMany(renderer.Component.Mixin, oldReconcilerMethods); + } else { + restoreMany(renderer.Reconciler, oldReconcilerMethods); + } + } + oldReconcilerMethods = null; + } + + function recordMount( + internalInstance: InternalInstance, + id: number, + parentID: number + ) { + const isRoot = parentID === 0; + + if (__DEBUG__) { + console.log( + '%crecordMount()', + 'color: green; font-weight: bold;', + id, + getData(internalInstance).displayName + ); + } + + if (isRoot) { + // TODO Is this right? For all versions? + const hasOwnerMetadata = + internalInstance._currentElement != null && + internalInstance._currentElement._owner != null; + + pushOperation(TREE_OPERATION_ADD); + pushOperation(id); + pushOperation(ElementTypeRoot); + pushOperation(0); // isProfilingSupported? + pushOperation(hasOwnerMetadata ? 1 : 0); + } else { + const type = getElementType(internalInstance); + const { displayName, key } = getData(internalInstance); + + const ownerID = + internalInstance._currentElement != null && + internalInstance._currentElement._owner != null + ? getID(internalInstance._currentElement._owner) + : 0; + + let displayNameStringID = getStringID(displayName); + let keyStringID = getStringID(key); + pushOperation(TREE_OPERATION_ADD); + pushOperation(id); + pushOperation(type); + pushOperation(parentID); + pushOperation(ownerID); + pushOperation(displayNameStringID); + pushOperation(keyStringID); + } + } + + function recordReorder( + internalInstance: InternalInstance, + id: number, + nextChildren: Array + ) { + pushOperation(TREE_OPERATION_REORDER_CHILDREN); + pushOperation(id); + const nextChildIDs = nextChildren.map(getID); + pushOperation(nextChildIDs.length); + for (let i = 0; i < nextChildIDs.length; i++) { + pushOperation(nextChildIDs[i]); + } + } + + function recordUnmount(internalInstance: InternalInstance, id: number) { + pendingUnmountedIDs.push(id); + idToInternalInstanceMap.delete(id); + } + + function crawlAndRecordInitialMounts( + id: number, + parentID: number, + rootID: number + ) { + const internalInstance = idToInternalInstanceMap.get(id); + + if (__DEBUG__) { + console.group('crawlAndRecordInitialMounts() id:', id); + } + + internalInstanceToRootIDMap.set(internalInstance, rootID); + recordMount(internalInstance, id, parentID); + getChildren(internalInstance).forEach(child => + crawlAndRecordInitialMounts(getID(child), id, rootID) + ); + + if (__DEBUG__) { + console.groupEnd(); + } + } + + function flushInitialOperations() { + // Crawl roots though and register any nodes that mounted before we were injected. + + const roots = + renderer.Mount._instancesByReactRootID || + renderer.Mount._instancesByContainerID; + + for (let key in roots) { + const internalInstance = roots[key]; + const id = getID(internalInstance); + crawlAndRecordInitialMounts(id, 0, id); + flushPendingEvents(id); + } + } + + let pendingOperations: Array = []; + let pendingStringTable: Map = new Map(); + let pendingUnmountedIDs: Array = []; + let pendingStringTableLength: number = 0; + let pendingUnmountedRootID: number | null = null; + + function flushPendingEvents(rootID: number) { + if ( + pendingOperations.length === 0 && + pendingUnmountedIDs.length === 0 && + pendingUnmountedRootID === null + ) { + return; + } + + const numUnmountIDs = + pendingUnmountedIDs.length + (pendingUnmountedRootID === null ? 0 : 1); + + const operations = new Array( + // Identify which renderer this update is coming from. + 2 + // [rendererID, rootFiberID] + // How big is the string table? + 1 + // [stringTableLength] + // Then goes the actual string table. + pendingStringTableLength + + // All unmounts are batched in a single message. + // [TREE_OPERATION_REMOVE, removedIDLength, ...ids] + (numUnmountIDs > 0 ? 2 + numUnmountIDs : 0) + + // Mount operations + pendingOperations.length + ); + + // Identify which renderer this update is coming from. + // This enables roots to be mapped to renderers, + // Which in turn enables fiber properations, states, and hooks to be inspected. + let i = 0; + operations[i++] = rendererID; + operations[i++] = rootID; + + // Now fill in the string table. + // [stringTableLength, str1Length, ...str1, str2Length, ...str2, ...] + operations[i++] = pendingStringTableLength; + pendingStringTable.forEach((value, key) => { + operations[i++] = key.length; + const encodedKey = utfEncodeString(key); + for (let j = 0; j < encodedKey.length; j++) { + operations[i + j] = encodedKey[j]; + } + i += key.length; + }); + + if (numUnmountIDs > 0) { + // All unmounts except roots are batched in a single message. + operations[i++] = TREE_OPERATION_REMOVE; + // The first number is how many unmounted IDs we're gonna send. + operations[i++] = numUnmountIDs; + // Fill in the unmounts + for (let j = 0; j < pendingUnmountedIDs.length; j++) { + operations[i++] = pendingUnmountedIDs[j]; + } + // The root ID should always be unmounted last. + if (pendingUnmountedRootID !== null) { + operations[i] = pendingUnmountedRootID; + i++; + } + } + + // Fill in the rest of the operations. + for (let j = 0; j < pendingOperations.length; j++) { + operations[i + j] = pendingOperations[j]; + } + i += pendingOperations.length; + + if (__DEBUG__) { + printOperationsArray(operations); + } + + // If we've already connected to the frontend, just pass the operations through. + hook.emit('operations', operations); + + pendingOperations.length = 0; + pendingUnmountedIDs = []; + pendingUnmountedRootID = null; + pendingStringTable.clear(); + pendingStringTableLength = 0; + } + + function pushOperation(op: number): void { + if (__DEV__) { + if (!Number.isInteger(op)) { + console.error( + 'pushOperation() was called but the value is not an integer.', + op + ); + } + } + pendingOperations.push(op); + } + + function getStringID(str: string | null): number { + if (str === null) { + return 0; + } + const existingID = pendingStringTable.get(str); + if (existingID !== undefined) { + return existingID; + } + const stringID = pendingStringTable.size + 1; + pendingStringTable.set(str, stringID); + // The string table total length needs to account + // both for the string length, and for the array item + // that contains the length itself. Hence + 1. + pendingStringTableLength += str.length + 1; + return stringID; + } + + 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 createIsPathWhitelisted(key: string) { + // 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 isPathWhitelisted(path: Array): boolean { + let current = currentlyInspectedPaths[key]; + if (!current) { + return false; + } + for (let i = 0; i < path.length; i++) { + current = current[path[i]]; + if (!current) { + return false; + } + } + return true; + }; + } + + // Fast path props lookup for React Native style editor. + function getInstanceAndStyle(id: number): InstanceAndStyle { + let instance = null; + let style = null; + + const internalInstance = idToInternalInstanceMap.get(id); + if (internalInstance != null) { + instance = internalInstance._instance || null; + + const element = internalInstance._currentElement; + if (element != null && element.props != null) { + style = element.props.style || null; + } + } + + return { + instance, + style, + }; + } + + function updateSelectedElement(id: number): void { + const internalInstance = idToInternalInstanceMap.get(id); + if (internalInstance == null) { + console.warn(`Could not find instance with id "${id}"`); + return; + } + + switch (getElementType(internalInstance)) { + case ElementTypeClass: + global.$r = internalInstance._instance; + break; + case ElementTypeFunction: + const element = internalInstance._currentElement; + if (element == null) { + console.warn(`Could not find element with id "${id}"`); + return; + } + + global.$r = { + props: element.props, + type: element.type, + }; + break; + default: + global.$r = null; + break; + } + } + + function inspectElement( + id: number, + path?: Array + ): InspectedElementPayload { + if (currentlyInspectedElementID !== id) { + currentlyInspectedElementID = id; + currentlyInspectedPaths = {}; + } + + const inspectedElement = inspectElementRaw(id); + if (inspectedElement === 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 dehyration (cleanForBridge). + updateSelectedElement(id); + + inspectedElement.context = cleanForBridge( + inspectedElement.context, + createIsPathWhitelisted('context') + ); + inspectedElement.props = cleanForBridge( + inspectedElement.props, + createIsPathWhitelisted('props') + ); + inspectedElement.state = cleanForBridge( + inspectedElement.state, + createIsPathWhitelisted('state') + ); + + return { + id, + type: 'full-data', + value: inspectedElement, + }; + } + + function inspectElementRaw(id: number): InspectedElement | null { + const internalInstance = idToInternalInstanceMap.get(id); + const displayName = getData(internalInstance).displayName; + const type = getElementType(internalInstance); + + let context = null; + let owners = null; + let props = null; + let state = null; + let source = null; + + if (internalInstance != null) { + const element = internalInstance._currentElement; + if (element !== null) { + props = element.props; + source = element._source != null ? element._source : null; + + let owner = element._owner; + if (owner) { + owners = []; + while (owner != null) { + owners.push({ + displayName: getData(owner).displayName || 'Unknown', + id: getID(owner), + type: getElementType(owner), + }); + if (owner._currentElement) { + owner = owner._currentElement._owner; + } + } + } + } + + const publicInstance = internalInstance._instance; + if (publicInstance != null) { + context = publicInstance.context || null; + state = publicInstance.state || null; + } + } + + return { + id, + + // Hooks did not exist in legacy versions + canEditHooks: false, + + // Does the current renderer support editable function props? + canEditFunctionProps: true, + + // Suspense did not exist in legacy versions + canToggleSuspense: false, + + // Can view component source location. + canViewSource: type === ElementTypeClass || type === ElementTypeFunction, + + displayName: displayName, + + type: type, + + // Inspectable properties. + context, + hooks: null, + props, + state, + + // List of owners + owners, + + // Location of component in source coude. + source, + }; + } + + function logElementToConsole(id: number): void { + const result = inspectElementRaw(id); + if (result === null) { + console.warn(`Could not find element with id "${id}"`); + return; + } + + const supportsGroup = typeof console.groupCollapsed === 'function'; + if (supportsGroup) { + console.groupCollapsed( + `[Click to expand] %c<${result.displayName || 'Component'} />`, + // --dom-tag-name-color is the CSS variable Chrome styles HTML elements with in the console. + 'color: var(--dom-tag-name-color); font-weight: normal;' + ); + } + if (result.props !== null) { + console.log('Props:', result.props); + } + if (result.state !== null) { + console.log('State:', result.state); + } + if (result.context !== null) { + console.log('Context:', result.context); + } + const nativeNode = findNativeNodeForInternalID(id); + if (nativeNode !== null) { + console.log('Node:', nativeNode); + } + if (window.chrome || /firefox/i.test(navigator.userAgent)) { + console.log( + 'Right-click any value to save it as a global variable for further inspection.' + ); + } + if (supportsGroup) { + console.groupEnd(); + } + } + + function prepareViewElementSource(id: number): void { + const internalInstance = idToInternalInstanceMap.get(id); + if (internalInstance == null) { + console.warn(`Could not find instance with id "${id}"`); + return; + } + + const element = internalInstance._currentElement; + if (element == null) { + console.warn(`Could not find element with id "${id}"`); + return; + } + + global.$type = element.type; + } + + function setInProps(id: number, path: Array, value: any) { + const internalInstance = idToInternalInstanceMap.get(id); + if (internalInstance != null) { + const element = internalInstance._currentElement; + internalInstance._currentElement = { + ...element, + props: copyWithSet(element.props, path, value), + }; + forceUpdate(internalInstance._instance); + } + } + + function setInState(id: number, path: Array, value: any) { + const internalInstance = idToInternalInstanceMap.get(id); + if (internalInstance != null) { + const publicInstance = internalInstance._instance; + if (publicInstance != null) { + setIn(publicInstance.state, path, value); + forceUpdate(publicInstance); + } + } + } + + function setInContext(id: number, path: Array, value: any) { + const internalInstance = idToInternalInstanceMap.get(id); + if (internalInstance != null) { + const publicInstance = internalInstance._instance; + if (publicInstance != null) { + setIn(publicInstance.context, path, value); + forceUpdate(publicInstance); + } + } + } + + function setIn(obj: Object, path: Array, value: any) { + const last = path.pop(); + const parent = path.reduce( + // $FlowFixMe + (reduced, attr) => (reduced ? reduced[attr] : null), + obj + ); + if (parent) { + // $FlowFixMe + parent[last] = value; + } + } + + // v16+ only features + const getProfilingData = () => { + throw new Error('getProfilingData not supported by this renderer'); + }; + const handleCommitFiberRoot = () => { + throw new Error('handleCommitFiberRoot not supported by this renderer'); + }; + const handleCommitFiberUnmount = () => { + throw new Error('handleCommitFiberUnmount not supported by this renderer'); + }; + const overrideSuspense = () => { + throw new Error('overrideSuspense not supported by this renderer'); + }; + const setInHook = () => { + throw new Error('setInHook not supported by this renderer'); + }; + const startProfiling = () => { + throw new Error('startProfiling not supported by this renderer'); + }; + const stopProfiling = () => { + throw new Error('stopProfiling not supported by this renderer'); + }; + + function getBestMatchForTrackedPath(): PathMatch | null { + // Not implemented. + return null; + } + + function getPathForElement(id: number): Array | null { + // Not implemented. + return null; + } + + function updateComponentFilters(componentFilters: Array) { + // Not implemented. + } + + function setTrackedPath(path: Array | null) { + // Not implemented. + } + + function getOwnersList(id: number): Array | null { + // Not implemented. + return null; + } + + return { + cleanup, + flushInitialOperations, + getBestMatchForTrackedPath, + getFiberIDForNative: getInternalIDForNative, + getInstanceAndStyle, + findNativeNodesForFiberID: (id: number) => { + const nativeNode = findNativeNodeForInternalID(id); + return nativeNode == null ? null : [nativeNode]; + }, + getOwnersList, + getPathForElement, + getProfilingData, + handleCommitFiberRoot, + handleCommitFiberUnmount, + inspectElement, + logElementToConsole, + overrideSuspense, + prepareViewElementSource, + renderer, + setInContext, + setInHook, + setInProps, + setInState, + setTrackedPath, + startProfiling, + stopProfiling, + updateComponentFilters, + }; +} diff --git a/extension/src/backend/legacy/utils.js b/extension/src/backend/legacy/utils.js new file mode 100644 index 0000000000000..9fbe3079355ef --- /dev/null +++ b/extension/src/backend/legacy/utils.js @@ -0,0 +1,39 @@ +// @flow + +import type { InternalInstance } from './renderer'; + +export function decorate(object: Object, attr: string, fn: Function): Function { + const old = object[attr]; + object[attr] = function(instance: InternalInstance) { + return fn.call(this, old, arguments); + }; + return old; +} + +export function decorateMany( + source: Object, + fns: { [attr: string]: Function } +): Object { + const olds = {}; + for (const name in fns) { + olds[name] = decorate(source, name, fns[name]); + } + return olds; +} + +export function restoreMany(source: Object, olds: Object): void { + for (let name in olds) { + source[name] = olds[name]; + } +} + +export function forceUpdate(instance: InternalInstance): void { + if (typeof instance.forceUpdate === 'function') { + instance.forceUpdate(); + } else if ( + instance.updater != null && + typeof instance.updater.enqueueForceUpdate === 'function' + ) { + instance.updater.enqueueForceUpdate(this, () => {}, 'forceUpdate'); + } +} diff --git a/extension/src/backend/renderer.js b/extension/src/backend/renderer.js new file mode 100644 index 0000000000000..63aae20c87882 --- /dev/null +++ b/extension/src/backend/renderer.js @@ -0,0 +1,3006 @@ +// @flow + +import { gte } from 'semver'; +import { + ComponentFilterDisplayName, + ComponentFilterElementType, + ComponentFilterHOC, + ComponentFilterLocation, + ElementTypeClass, + ElementTypeContext, + ElementTypeFunction, + ElementTypeForwardRef, + ElementTypeHostComponent, + ElementTypeMemo, + ElementTypeOtherOrUnknown, + ElementTypeProfiler, + ElementTypeRoot, + ElementTypeSuspense, +} from 'src/types'; +import { + getDisplayName, + getDefaultComponentFilters, + getInObject, + getUID, + setInObject, + utfEncodeString, +} from 'src/utils'; +import { sessionStorageGetItem } from 'src/storage'; +import { cleanForBridge, copyWithSet } from './utils'; +import { + __DEBUG__, + SESSION_STORAGE_RELOAD_AND_PROFILE_KEY, + SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY, + TREE_OPERATION_ADD, + TREE_OPERATION_REMOVE, + TREE_OPERATION_REORDER_CHILDREN, + TREE_OPERATION_UPDATE_TREE_BASE_DURATION, +} from '../constants'; +import { inspectHooksOfFiber } from './ReactDebugHooks'; +import { + patch as patchConsole, + registerRenderer as registerRendererWithConsole, +} from './console'; + +import type { + ChangeDescription, + CommitDataBackend, + DevToolsHook, + Fiber, + InspectedElement, + InspectedElementPayload, + InstanceAndStyle, + Owner, + PathFrame, + PathMatch, + ProfilingDataBackend, + ProfilingDataForRootBackend, + ReactRenderer, + RendererInterface, +} from './types'; +import type { Interaction } from 'src/devtools/views/Profiler/types'; +import type { ComponentFilter, ElementType } from 'src/types'; + +type getDisplayNameForFiberType = (fiber: Fiber) => string | null; +type getTypeSymbolType = (type: any) => Symbol | number; + +type ReactSymbolsType = { + CONCURRENT_MODE_NUMBER: number, + CONCURRENT_MODE_SYMBOL_STRING: string, + DEPRECATED_ASYNC_MODE_SYMBOL_STRING: string, + CONTEXT_CONSUMER_NUMBER: number, + CONTEXT_CONSUMER_SYMBOL_STRING: string, + CONTEXT_PROVIDER_NUMBER: number, + CONTEXT_PROVIDER_SYMBOL_STRING: string, + EVENT_COMPONENT_NUMBER: number, + EVENT_COMPONENT_STRING: string, + EVENT_TARGET_NUMBER: number, + EVENT_TARGET_STRING: string, + EVENT_TARGET_TOUCH_HIT_NUMBER: number, + EVENT_TARGET_TOUCH_HIT_STRING: string, + FORWARD_REF_NUMBER: number, + FORWARD_REF_SYMBOL_STRING: string, + MEMO_NUMBER: number, + MEMO_SYMBOL_STRING: string, + PROFILER_NUMBER: number, + PROFILER_SYMBOL_STRING: string, + STRICT_MODE_NUMBER: number, + STRICT_MODE_SYMBOL_STRING: string, + SUSPENSE_NUMBER: number, + SUSPENSE_SYMBOL_STRING: string, + DEPRECATED_PLACEHOLDER_SYMBOL_STRING: string, +}; + +type ReactPriorityLevelsType = {| + ImmediatePriority: number, + UserBlockingPriority: number, + NormalPriority: number, + LowPriority: number, + IdlePriority: number, + NoPriority: number, +|}; + +type ReactTypeOfWorkType = {| + ClassComponent: number, + ContextConsumer: number, + ContextProvider: number, + CoroutineComponent: number, + CoroutineHandlerPhase: number, + DehydratedSuspenseComponent: number, + ForwardRef: number, + Fragment: number, + FunctionComponent: number, + HostComponent: number, + HostPortal: number, + HostRoot: number, + HostText: number, + IncompleteClassComponent: number, + IndeterminateComponent: number, + LazyComponent: number, + MemoComponent: number, + Mode: number, + Profiler: number, + SimpleMemoComponent: number, + SuspenseComponent: number, + YieldComponent: number, +|}; + +type ReactTypeOfSideEffectType = {| + NoEffect: number, + PerformedWork: number, + Placement: number, +|}; + +// Some environments (e.g. React Native / Hermes) don't support the performace API yet. +const getCurrentTime = + typeof performance === 'object' && typeof performance.now === 'function' + ? () => performance.now() + : () => Date.now(); + +export function getInternalReactConstants( + version: string +): {| + getDisplayNameForFiber: getDisplayNameForFiberType, + getTypeSymbol: getTypeSymbolType, + ReactPriorityLevels: ReactPriorityLevelsType, + ReactSymbols: ReactSymbolsType, + ReactTypeOfSideEffect: ReactTypeOfSideEffectType, + ReactTypeOfWork: ReactTypeOfWorkType, +|} { + const ReactSymbols: ReactSymbolsType = { + CONCURRENT_MODE_NUMBER: 0xeacf, + CONCURRENT_MODE_SYMBOL_STRING: 'Symbol(react.concurrent_mode)', + DEPRECATED_ASYNC_MODE_SYMBOL_STRING: 'Symbol(react.async_mode)', + CONTEXT_CONSUMER_NUMBER: 0xeace, + CONTEXT_CONSUMER_SYMBOL_STRING: 'Symbol(react.context)', + CONTEXT_PROVIDER_NUMBER: 0xeacd, + CONTEXT_PROVIDER_SYMBOL_STRING: 'Symbol(react.provider)', + EVENT_COMPONENT_NUMBER: 0xead5, + EVENT_COMPONENT_STRING: 'Symbol(react.event_component)', + EVENT_TARGET_NUMBER: 0xead6, + EVENT_TARGET_STRING: 'Symbol(react.event_target)', + EVENT_TARGET_TOUCH_HIT_NUMBER: 0xead7, + EVENT_TARGET_TOUCH_HIT_STRING: 'Symbol(react.event_target.touch_hit)', + FORWARD_REF_NUMBER: 0xead0, + FORWARD_REF_SYMBOL_STRING: 'Symbol(react.forward_ref)', + MEMO_NUMBER: 0xead3, + MEMO_SYMBOL_STRING: 'Symbol(react.memo)', + PROFILER_NUMBER: 0xead2, + PROFILER_SYMBOL_STRING: 'Symbol(react.profiler)', + STRICT_MODE_NUMBER: 0xeacc, + STRICT_MODE_SYMBOL_STRING: 'Symbol(react.strict_mode)', + SUSPENSE_NUMBER: 0xead1, + SUSPENSE_SYMBOL_STRING: 'Symbol(react.suspense)', + DEPRECATED_PLACEHOLDER_SYMBOL_STRING: 'Symbol(react.placeholder)', + }; + + const ReactTypeOfSideEffect: ReactTypeOfSideEffectType = { + NoEffect: 0b00, + PerformedWork: 0b01, + Placement: 0b10, + }; + + // ********************************************************** + // The section below is copied from files in React repo. + // Keep it in sync, and add version guards if it changes. + // + // Technically these priority levels are invalid for versions before 16.9, + // but 16.9 is the first version to report priority level to DevTools, + // so we can avoid checking for earlier versions and support pre-16.9 canary releases in the process. + const ReactPriorityLevels: ReactPriorityLevelsType = { + ImmediatePriority: 99, + UserBlockingPriority: 98, + NormalPriority: 97, + LowPriority: 96, + IdlePriority: 95, + NoPriority: 90, + }; + + let ReactTypeOfWork: ReactTypeOfWorkType = ((null: any): ReactTypeOfWorkType); + + // ********************************************************** + // The section below is copied from files in React repo. + // Keep it in sync, and add version guards if it changes. + if (gte(version, '16.6.0-beta.0')) { + ReactTypeOfWork = { + ClassComponent: 1, + ContextConsumer: 9, + ContextProvider: 10, + CoroutineComponent: -1, // Removed + CoroutineHandlerPhase: -1, // Removed + DehydratedSuspenseComponent: 18, // Behind a flag + ForwardRef: 11, + Fragment: 7, + FunctionComponent: 0, + HostComponent: 5, + HostPortal: 4, + HostRoot: 3, + HostText: 6, + IncompleteClassComponent: 17, + IndeterminateComponent: 2, + LazyComponent: 16, + MemoComponent: 14, + Mode: 8, + Profiler: 12, + SimpleMemoComponent: 15, + SuspenseComponent: 13, + YieldComponent: -1, // Removed + }; + } else if (gte(version, '16.4.3-alpha')) { + ReactTypeOfWork = { + ClassComponent: 2, + ContextConsumer: 11, + ContextProvider: 12, + CoroutineComponent: -1, // Removed + CoroutineHandlerPhase: -1, // Removed + DehydratedSuspenseComponent: -1, // Doesn't exist yet + ForwardRef: 13, + Fragment: 9, + FunctionComponent: 0, + HostComponent: 7, + HostPortal: 6, + HostRoot: 5, + HostText: 8, + IncompleteClassComponent: -1, // Doesn't exist yet + IndeterminateComponent: 4, + LazyComponent: -1, // Doesn't exist yet + MemoComponent: -1, // Doesn't exist yet + Mode: 10, + Profiler: 15, + SimpleMemoComponent: -1, // Doesn't exist yet + SuspenseComponent: 16, + YieldComponent: -1, // Removed + }; + } else { + ReactTypeOfWork = { + ClassComponent: 2, + ContextConsumer: 12, + ContextProvider: 13, + CoroutineComponent: 7, + CoroutineHandlerPhase: 8, + DehydratedSuspenseComponent: -1, // Doesn't exist yet + ForwardRef: 14, + Fragment: 10, + FunctionComponent: 1, + HostComponent: 5, + HostPortal: 4, + HostRoot: 3, + HostText: 6, + IncompleteClassComponent: -1, // Doesn't exist yet + IndeterminateComponent: 0, + LazyComponent: -1, // Doesn't exist yet + MemoComponent: -1, // Doesn't exist yet + Mode: 11, + Profiler: 15, + SimpleMemoComponent: -1, // Doesn't exist yet + SuspenseComponent: 16, + YieldComponent: 9, + }; + } + // ********************************************************** + // End of copied code. + // ********************************************************** + + function getTypeSymbol(type: any): Symbol | number { + const symbolOrNumber = + typeof type === 'object' && type !== null ? type.$$typeof : type; + + return typeof symbolOrNumber === 'symbol' + ? symbolOrNumber.toString() + : symbolOrNumber; + } + + const { + ClassComponent, + IncompleteClassComponent, + FunctionComponent, + IndeterminateComponent, + ForwardRef, + HostRoot, + HostComponent, + HostPortal, + HostText, + Fragment, + MemoComponent, + SimpleMemoComponent, + } = ReactTypeOfWork; + + const { + CONCURRENT_MODE_NUMBER, + CONCURRENT_MODE_SYMBOL_STRING, + DEPRECATED_ASYNC_MODE_SYMBOL_STRING, + CONTEXT_PROVIDER_NUMBER, + CONTEXT_PROVIDER_SYMBOL_STRING, + CONTEXT_CONSUMER_NUMBER, + CONTEXT_CONSUMER_SYMBOL_STRING, + STRICT_MODE_NUMBER, + STRICT_MODE_SYMBOL_STRING, + SUSPENSE_NUMBER, + SUSPENSE_SYMBOL_STRING, + DEPRECATED_PLACEHOLDER_SYMBOL_STRING, + PROFILER_NUMBER, + PROFILER_SYMBOL_STRING, + } = ReactSymbols; + + // NOTICE Keep in sync with shouldFilterFiber() and other get*ForFiber methods + function getDisplayNameForFiber(fiber: Fiber): string | null { + const { elementType, type, tag } = fiber; + + // This is to support lazy components with a Promise as the type. + // see https://github.com/facebook/react/pull/13397 + let resolvedType = type; + if (typeof type === 'object' && type !== null) { + if (typeof type.then === 'function') { + resolvedType = type._reactResult; + } + } + + let resolvedContext: any = null; + + switch (tag) { + case ClassComponent: + case IncompleteClassComponent: + return getDisplayName(resolvedType); + case FunctionComponent: + case IndeterminateComponent: + return getDisplayName(resolvedType); + case ForwardRef: + return ( + resolvedType.displayName || + getDisplayName(resolvedType.render, 'Anonymous') + ); + case HostRoot: + return null; + case HostComponent: + return type; + case HostPortal: + case HostText: + case Fragment: + return null; + case MemoComponent: + case SimpleMemoComponent: + if (elementType.displayName) { + return elementType.displayName; + } else { + return getDisplayName(type, 'Anonymous'); + } + default: + const typeSymbol = getTypeSymbol(type); + + switch (typeSymbol) { + case CONCURRENT_MODE_NUMBER: + case CONCURRENT_MODE_SYMBOL_STRING: + case DEPRECATED_ASYNC_MODE_SYMBOL_STRING: + return null; + case CONTEXT_PROVIDER_NUMBER: + case CONTEXT_PROVIDER_SYMBOL_STRING: + // 16.3.0 exposed the context object as "context" + // PR #12501 changed it to "_context" for 16.3.1+ + // NOTE Keep in sync with inspectElementRaw() + resolvedContext = fiber.type._context || fiber.type.context; + return `${resolvedContext.displayName || 'Context'}.Provider`; + case CONTEXT_CONSUMER_NUMBER: + case CONTEXT_CONSUMER_SYMBOL_STRING: + // 16.3-16.5 read from "type" because the Consumer is the actual context object. + // 16.6+ should read from "type._context" because Consumer can be different (in DEV). + // NOTE Keep in sync with inspectElementRaw() + resolvedContext = fiber.type._context || fiber.type; + + // NOTE: TraceUpdatesBackendManager depends on the name ending in '.Consumer' + // If you change the name, figure out a more resilient way to detect it. + return `${resolvedContext.displayName || 'Context'}.Consumer`; + case STRICT_MODE_NUMBER: + case STRICT_MODE_SYMBOL_STRING: + return null; + case SUSPENSE_NUMBER: + case SUSPENSE_SYMBOL_STRING: + case DEPRECATED_PLACEHOLDER_SYMBOL_STRING: + return 'Suspense'; + case PROFILER_NUMBER: + case PROFILER_SYMBOL_STRING: + return `Profiler(${fiber.memoizedProps.id})`; + default: + // Unknown element type. + // This may mean a new element type that has not yet been added to DevTools. + return null; + } + } + } + + return { + getDisplayNameForFiber, + getTypeSymbol, + ReactPriorityLevels, + ReactTypeOfWork, + ReactSymbols, + ReactTypeOfSideEffect, + }; +} + +export function attach( + hook: DevToolsHook, + rendererID: number, + renderer: ReactRenderer, + global: Object +): RendererInterface { + const { + getDisplayNameForFiber, + getTypeSymbol, + ReactPriorityLevels, + ReactTypeOfWork, + ReactSymbols, + ReactTypeOfSideEffect, + } = getInternalReactConstants(renderer.version); + const { NoEffect, PerformedWork, Placement } = ReactTypeOfSideEffect; + const { + FunctionComponent, + ClassComponent, + ContextConsumer, + DehydratedSuspenseComponent, + Fragment, + ForwardRef, + HostRoot, + HostPortal, + HostComponent, + HostText, + IncompleteClassComponent, + IndeterminateComponent, + MemoComponent, + SimpleMemoComponent, + SuspenseComponent, + } = ReactTypeOfWork; + const { + ImmediatePriority, + UserBlockingPriority, + NormalPriority, + LowPriority, + IdlePriority, + NoPriority, + } = ReactPriorityLevels; + const { + CONCURRENT_MODE_NUMBER, + CONCURRENT_MODE_SYMBOL_STRING, + DEPRECATED_ASYNC_MODE_SYMBOL_STRING, + CONTEXT_CONSUMER_NUMBER, + CONTEXT_CONSUMER_SYMBOL_STRING, + CONTEXT_PROVIDER_NUMBER, + CONTEXT_PROVIDER_SYMBOL_STRING, + PROFILER_NUMBER, + PROFILER_SYMBOL_STRING, + STRICT_MODE_NUMBER, + STRICT_MODE_SYMBOL_STRING, + SUSPENSE_NUMBER, + SUSPENSE_SYMBOL_STRING, + DEPRECATED_PLACEHOLDER_SYMBOL_STRING, + } = ReactSymbols; + + const { + overrideHookState, + overrideProps, + setSuspenseHandler, + scheduleUpdate, + } = renderer; + const supportsTogglingSuspense = + typeof setSuspenseHandler === 'function' && + typeof scheduleUpdate === 'function'; + + // Patching the console enables DevTools to do a few useful things: + // * Append component stacks to warnings and error messages + // * Disable logging during re-renders to inspect hooks (see inspectHooksOfFiber) + // + // Don't patch in test environments because we don't want to interfere with Jest's own console overrides. + if (process.env.NODE_ENV !== 'test') { + registerRendererWithConsole(renderer); + + // The renderer interface can't read this preference directly, + // because it is stored in localStorage within the context of the extension. + // It relies on the extension to pass the preference through via the global. + if (window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ !== false) { + patchConsole(); + } + } + + const debug = (name: string, fiber: Fiber, parentFiber: ?Fiber): void => { + if (__DEBUG__) { + const displayName = getDisplayNameForFiber(fiber) || 'null'; + const parentDisplayName = + (parentFiber != null && getDisplayNameForFiber(parentFiber)) || 'null'; + // NOTE: calling getFiberID or getPrimaryFiber is unsafe here + // because it will put them in the map. For now, we'll omit them. + // TODO: better debugging story for this. + console.log( + `[renderer] %c${name} %c${displayName} %c${ + parentFiber ? parentDisplayName : '' + }`, + 'color: red; font-weight: bold;', + 'color: blue;', + 'color: purple;' + ); + } + }; + + // Configurable Components tree filters. + const hideElementsWithDisplayNames: Set = new Set(); + const hideElementsWithPaths: Set = new Set(); + const hideElementsWithTypes: Set = new Set(); + + function applyComponentFilters(componentFilters: Array) { + hideElementsWithTypes.clear(); + hideElementsWithDisplayNames.clear(); + hideElementsWithPaths.clear(); + + componentFilters.forEach(componentFilter => { + if (!componentFilter.isEnabled) { + return; + } + + switch (componentFilter.type) { + case ComponentFilterDisplayName: + if (componentFilter.isValid && componentFilter.value !== '') { + hideElementsWithDisplayNames.add( + new RegExp(componentFilter.value, 'i') + ); + } + break; + case ComponentFilterElementType: + hideElementsWithTypes.add(componentFilter.value); + break; + case ComponentFilterLocation: + if (componentFilter.isValid && componentFilter.value !== '') { + hideElementsWithPaths.add(new RegExp(componentFilter.value, 'i')); + } + break; + case ComponentFilterHOC: + hideElementsWithDisplayNames.add(new RegExp('\\(')); + break; + default: + console.warn( + `Invalid component filter type "${componentFilter.type}"` + ); + break; + } + }); + } + + // The renderer interface can't read saved component filters directly, + // because they are stored in localStorage within the context of the extension. + // Instead it relies on the extension to pass filters through. + if (window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ != null) { + applyComponentFilters(window.__REACT_DEVTOOLS_COMPONENT_FILTERS__); + } else { + // Unfortunately this feature is not expected to work for React Native for now. + // It would be annoying for us to spam YellowBox warnings with unactionable stuff, + // so for now just skip this message... + //console.warn('⚛️ DevTools: Could not locate saved component filters'); + + // Fallback to assuming the default filters in this case. + applyComponentFilters(getDefaultComponentFilters()); + } + + // If necessary, we can revisit optimizing this operation. + // For example, we could add a new recursive unmount tree operation. + // The unmount operations are already significantly smaller than mount opreations though. + // This is something to keep in mind for later. + function updateComponentFilters(componentFilters: Array) { + if (isProfiling) { + // Re-mounting a tree while profiling is in progress might break a lot of assumptions. + // If necessary, we could support this- but it doesn't seem like a necessary use case. + throw Error('Cannot modify filter preferences while profiling'); + } + + // Recursively unmount all roots. + hook.getFiberRoots(rendererID).forEach(root => { + currentRootID = getFiberID(getPrimaryFiber(root.current)); + unmountFiberChildrenRecursively(root.current); + recordUnmount(root.current, false); + currentRootID = -1; + }); + + applyComponentFilters(componentFilters); + + // Reset psuedo counters so that new path selections will be persisted. + rootDisplayNameCounter.clear(); + + // Recursively re-mount all roots with new filter criteria applied. + hook.getFiberRoots(rendererID).forEach(root => { + currentRootID = getFiberID(getPrimaryFiber(root.current)); + setRootPseudoKey(currentRootID, root.current); + mountFiberRecursively(root.current, null); + flushPendingEvents(root); + currentRootID = -1; + }); + } + + // NOTICE Keep in sync with get*ForFiber methods + function shouldFilterFiber(fiber: Fiber): boolean { + const { _debugSource, tag, type } = fiber; + + switch (tag) { + case DehydratedSuspenseComponent: + // TODO: ideally we would show dehydrated Suspense immediately. + // However, it has some special behavior (like disconnecting + // an alternate and turning into real Suspense) which breaks DevTools. + // For now, ignore it, and only show it once it gets hydrated. + // https://github.com/bvaughn/react-devtools-experimental/issues/197 + return true; + case HostPortal: + case HostText: + case Fragment: + return true; + case HostRoot: + // It is never valid to filter the root element. + return false; + default: + const typeSymbol = getTypeSymbol(type); + + switch (typeSymbol) { + case CONCURRENT_MODE_NUMBER: + case CONCURRENT_MODE_SYMBOL_STRING: + case DEPRECATED_ASYNC_MODE_SYMBOL_STRING: + case STRICT_MODE_NUMBER: + case STRICT_MODE_SYMBOL_STRING: + return true; + default: + break; + } + } + + const elementType = getElementTypeForFiber(fiber); + if (hideElementsWithTypes.has(elementType)) { + return true; + } + + if (hideElementsWithDisplayNames.size > 0) { + const displayName = getDisplayNameForFiber(fiber); + if (displayName != null) { + for (let displayNameRegExp of hideElementsWithDisplayNames) { + if (displayNameRegExp.test(displayName)) { + return true; + } + } + } + } + + if (_debugSource != null && hideElementsWithPaths.size > 0) { + const { fileName } = _debugSource; + for (let pathRegExp of hideElementsWithPaths) { + if (pathRegExp.test(fileName)) { + return true; + } + } + } + + return false; + } + // NOTICE Keep in sync with shouldFilterFiber() and other get*ForFiber methods + function getElementTypeForFiber(fiber: Fiber): ElementType { + const { type, tag } = fiber; + + switch (tag) { + case ClassComponent: + case IncompleteClassComponent: + return ElementTypeClass; + case FunctionComponent: + case IndeterminateComponent: + return ElementTypeFunction; + case ForwardRef: + return ElementTypeForwardRef; + case HostRoot: + return ElementTypeRoot; + case HostComponent: + return ElementTypeHostComponent; + case HostPortal: + case HostText: + case Fragment: + return ElementTypeOtherOrUnknown; + case MemoComponent: + case SimpleMemoComponent: + return ElementTypeMemo; + default: + const typeSymbol = getTypeSymbol(type); + + switch (typeSymbol) { + case CONCURRENT_MODE_NUMBER: + case CONCURRENT_MODE_SYMBOL_STRING: + case DEPRECATED_ASYNC_MODE_SYMBOL_STRING: + return ElementTypeOtherOrUnknown; + case CONTEXT_PROVIDER_NUMBER: + case CONTEXT_PROVIDER_SYMBOL_STRING: + return ElementTypeContext; + case CONTEXT_CONSUMER_NUMBER: + case CONTEXT_CONSUMER_SYMBOL_STRING: + return ElementTypeContext; + case STRICT_MODE_NUMBER: + case STRICT_MODE_SYMBOL_STRING: + return ElementTypeOtherOrUnknown; + case SUSPENSE_NUMBER: + case SUSPENSE_SYMBOL_STRING: + case DEPRECATED_PLACEHOLDER_SYMBOL_STRING: + return ElementTypeSuspense; + case PROFILER_NUMBER: + case PROFILER_SYMBOL_STRING: + return ElementTypeProfiler; + default: + return ElementTypeOtherOrUnknown; + } + } + } + + // This is a slightly annoying indirection. + // It is currently necessary because DevTools wants to use unique objects as keys for instances. + // However fibers have two versions. + // We use this set to remember first encountered fiber for each conceptual instance. + function getPrimaryFiber(fiber: Fiber): Fiber { + if (primaryFibers.has(fiber)) { + return fiber; + } + const { alternate } = fiber; + if (alternate != null && primaryFibers.has(alternate)) { + return alternate; + } + primaryFibers.add(fiber); + return fiber; + } + + const fiberToIDMap: Map = new Map(); + const idToFiberMap: Map = new Map(); + const primaryFibers: Set = new Set(); + + // When profiling is supported, we store the latest tree base durations for each Fiber. + // This is so that we can quickly capture a snapshot of those values if profiling starts. + // If we didn't store these values, we'd have to crawl the tree when profiling started, + // and use a slow path to find each of the current Fibers. + const idToTreeBaseDurationMap: Map = new Map(); + + // When profiling is supported, we store the latest tree base durations for each Fiber. + // This map enables us to filter these times by root when sending them to the frontend. + const idToRootMap: Map = new Map(); + + // When a mount or update is in progress, this value tracks the root that is being operated on. + let currentRootID: number = -1; + + function getFiberID(primaryFiber: Fiber): number { + if (!fiberToIDMap.has(primaryFiber)) { + const id = getUID(); + fiberToIDMap.set(primaryFiber, id); + idToFiberMap.set(id, primaryFiber); + } + return ((fiberToIDMap.get(primaryFiber): any): number); + } + + function getChangeDescription( + prevFiber: Fiber | null, + nextFiber: Fiber + ): ChangeDescription | null { + switch (getElementTypeForFiber(nextFiber)) { + case ElementTypeClass: + case ElementTypeFunction: + case ElementTypeMemo: + case ElementTypeForwardRef: + if (prevFiber === null) { + return { + context: null, + didHooksChange: false, + isFirstMount: true, + props: null, + state: null, + }; + } else { + return { + context: getContextChangedKeys(nextFiber), + didHooksChange: didHooksChange( + prevFiber.memoizedState, + nextFiber.memoizedState + ), + isFirstMount: false, + props: getChangedKeys( + prevFiber.memoizedProps, + nextFiber.memoizedProps + ), + state: getChangedKeys( + prevFiber.memoizedState, + nextFiber.memoizedState + ), + }; + } + default: + return null; + } + } + + function updateContextsForFiber(fiber: Fiber) { + switch (getElementTypeForFiber(fiber)) { + case ElementTypeClass: + if (idToContextsMap !== null) { + const id = getFiberID(getPrimaryFiber(fiber)); + const contexts = getContextsForFiber(fiber); + if (contexts !== null) { + idToContextsMap.set(id, contexts); + } + } + break; + default: + break; + } + } + + // Differentiates between a null context value and no context. + const NO_CONTEXT = {}; + + function getContextsForFiber(fiber: Fiber): [Object, any] | null { + switch (getElementTypeForFiber(fiber)) { + case ElementTypeClass: + const instance = fiber.stateNode; + let legacyContext = NO_CONTEXT; + let modernContext = NO_CONTEXT; + if (instance != null) { + if ( + instance.constructor && + instance.constructor.contextType != null + ) { + modernContext = instance.context; + } else { + legacyContext = instance.context; + if (legacyContext && Object.keys(legacyContext).length === 0) { + legacyContext = NO_CONTEXT; + } + } + } + return [legacyContext, modernContext]; + default: + return null; + } + } + + // Record all contexts at the time profiling is started. + // Fibers only store the current context value, + // so we need to track them separatenly in order to determine changed keys. + function crawlToInitializeContextsMap(fiber: Fiber) { + updateContextsForFiber(fiber); + let current = fiber.child; + while (current !== null) { + crawlToInitializeContextsMap(current); + current = current.sibling; + } + } + + function getContextChangedKeys(fiber: Fiber): null | boolean | Array { + switch (getElementTypeForFiber(fiber)) { + case ElementTypeClass: + if (idToContextsMap !== null) { + const id = getFiberID(getPrimaryFiber(fiber)); + const prevContexts = idToContextsMap.has(id) + ? idToContextsMap.get(id) + : null; + const nextContexts = getContextsForFiber(fiber); + + if (prevContexts == null || nextContexts == null) { + return null; + } + + const [prevLegacyContext, prevModernContext] = prevContexts; + const [nextLegacyContext, nextModernContext] = nextContexts; + + if (nextLegacyContext !== NO_CONTEXT) { + return getChangedKeys(prevLegacyContext, nextLegacyContext); + } else if (nextModernContext !== NO_CONTEXT) { + return prevModernContext !== nextModernContext; + } + } + break; + default: + break; + } + return null; + } + + function didHooksChange(prev: any, next: any): boolean { + if (next == null) { + return false; + } + + // We can't report anything meaningful for hooks changes. + if ( + next.hasOwnProperty('baseState') && + next.hasOwnProperty('memoizedState') && + next.hasOwnProperty('next') && + next.hasOwnProperty('queue') + ) { + while (next !== null) { + if (next.memoizedState !== prev.memoizedState) { + return true; + } else { + next = next.next; + prev = prev.next; + } + } + } + + return false; + } + + function getChangedKeys(prev: any, next: any): null | Array { + if (prev == null || next == null) { + return null; + } + + // We can't report anything meaningful for hooks changes. + if ( + next.hasOwnProperty('baseState') && + next.hasOwnProperty('memoizedState') && + next.hasOwnProperty('next') && + next.hasOwnProperty('queue') + ) { + return null; + } + + const keys = new Set([...Object.keys(prev), ...Object.keys(next)]); + const changedKeys = []; + for (let key of keys) { + if (prev[key] !== next[key]) { + changedKeys.push(key); + } + } + + return changedKeys; + } + + // eslint-disable-next-line no-unused-vars + function didFiberRender(prevFiber: Fiber, nextFiber: Fiber): boolean { + switch (nextFiber.tag) { + case ClassComponent: + case FunctionComponent: + case ContextConsumer: + case MemoComponent: + case SimpleMemoComponent: + // For types that execute user code, we check PerformedWork effect. + // We don't reflect bailouts (either referential or sCU) in DevTools. + // eslint-disable-next-line no-bitwise + return (nextFiber.effectTag & PerformedWork) === PerformedWork; + // Note: ContextConsumer only gets PerformedWork effect in 16.3.3+ + // so it won't get highlighted with React 16.3.0 to 16.3.2. + default: + // For host components and other types, we compare inputs + // to determine whether something is an update. + return ( + prevFiber.memoizedProps !== nextFiber.memoizedProps || + prevFiber.memoizedState !== nextFiber.memoizedState || + prevFiber.ref !== nextFiber.ref + ); + } + } + + let pendingOperations: Array = []; + let pendingRealUnmountedIDs: Array = []; + let pendingSimulatedUnmountedIDs: Array = []; + let pendingOperationsQueue: Array> | null = []; + let pendingStringTable: Map = new Map(); + let pendingStringTableLength: number = 0; + let pendingUnmountedRootID: number | null = null; + + function pushOperation(op: number): void { + if (__DEV__) { + if (!Number.isInteger(op)) { + console.error( + 'pushOperation() was called but the value is not an integer.', + op + ); + } + } + pendingOperations.push(op); + } + + function flushPendingEvents(root: Object): void { + if ( + pendingOperations.length === 0 && + pendingRealUnmountedIDs.length === 0 && + pendingSimulatedUnmountedIDs.length === 0 && + pendingUnmountedRootID === null + ) { + // If we're currently profiling, send an "operations" method even if there are no mutations to the tree. + // The frontend needs this no-op info to know how to reconstruct the tree for each commit, + // even if a particular commit didn't change the shape of the tree. + if (!isProfiling) { + return; + } + } + + const numUnmountIDs = + pendingRealUnmountedIDs.length + + pendingSimulatedUnmountedIDs.length + + (pendingUnmountedRootID === null ? 0 : 1); + + const operations = new Array( + // Identify which renderer this update is coming from. + 2 + // [rendererID, rootFiberID] + // How big is the string table? + 1 + // [stringTableLength] + // Then goes the actual string table. + pendingStringTableLength + + // All unmounts are batched in a single message. + // [TREE_OPERATION_REMOVE, removedIDLength, ...ids] + (numUnmountIDs > 0 ? 2 + numUnmountIDs : 0) + + // Regular operations + pendingOperations.length + ); + + // Identify which renderer this update is coming from. + // This enables roots to be mapped to renderers, + // Which in turn enables fiber props, states, and hooks to be inspected. + let i = 0; + operations[i++] = rendererID; + operations[i++] = currentRootID; // Use this ID in case the root was unmounted! + + // Now fill in the string table. + // [stringTableLength, str1Length, ...str1, str2Length, ...str2, ...] + operations[i++] = pendingStringTableLength; + pendingStringTable.forEach((value, key) => { + operations[i++] = key.length; + const encodedKey = utfEncodeString(key); + for (let j = 0; j < encodedKey.length; j++) { + operations[i + j] = encodedKey[j]; + } + i += key.length; + }); + + if (numUnmountIDs > 0) { + // All unmounts except roots are batched in a single message. + operations[i++] = TREE_OPERATION_REMOVE; + // The first number is how many unmounted IDs we're gonna send. + operations[i++] = numUnmountIDs; + // Fill in the real unmounts in the reverse order. + // They were inserted parents-first by React, but we want children-first. + // So we traverse our array backwards. + for (let j = pendingRealUnmountedIDs.length - 1; j >= 0; j--) { + operations[i++] = pendingRealUnmountedIDs[j]; + } + // Fill in the simulated unmounts (hidden Suspense subtrees) in their order. + // (We want children to go before parents.) + // They go *after* the real unmounts because we know for sure they won't be + // children of already pushed "real" IDs. If they were, we wouldn't be able + // to discover them during the traversal, as they would have been deleted. + for (let j = 0; j < pendingSimulatedUnmountedIDs.length; j++) { + operations[i + j] = pendingSimulatedUnmountedIDs[j]; + } + i += pendingSimulatedUnmountedIDs.length; + // The root ID should always be unmounted last. + if (pendingUnmountedRootID !== null) { + operations[i] = pendingUnmountedRootID; + i++; + } + } + // Fill in the rest of the operations. + for (let j = 0; j < pendingOperations.length; j++) { + operations[i + j] = pendingOperations[j]; + } + i += pendingOperations.length; + + // Let the frontend know about tree operations. + // The first value in this array will identify which root it corresponds to, + // so we do no longer need to dispatch a separate root-committed event. + if (pendingOperationsQueue !== null) { + // Until the frontend has been connected, store the tree operations. + // This will let us avoid walking the tree later when the frontend connects, + // and it enables the Profiler's reload-and-profile functionality to work as well. + pendingOperationsQueue.push(operations); + } else { + // If we've already connected to the frontend, just pass the operations through. + hook.emit('operations', operations); + } + + pendingOperations.length = 0; + pendingRealUnmountedIDs.length = 0; + pendingSimulatedUnmountedIDs.length = 0; + pendingUnmountedRootID = null; + pendingStringTable.clear(); + pendingStringTableLength = 0; + } + + function getStringID(str: string | null): number { + if (str === null) { + return 0; + } + const existingID = pendingStringTable.get(str); + if (existingID !== undefined) { + return existingID; + } + const stringID = pendingStringTable.size + 1; + pendingStringTable.set(str, stringID); + // The string table total length needs to account + // both for the string length, and for the array item + // that contains the length itself. Hence + 1. + pendingStringTableLength += str.length + 1; + return stringID; + } + + function recordMount(fiber: Fiber, parentFiber: Fiber | null) { + const isRoot = fiber.tag === HostRoot; + const id = getFiberID(getPrimaryFiber(fiber)); + + const hasOwnerMetadata = fiber.hasOwnProperty('_debugOwner'); + const isProfilingSupported = fiber.hasOwnProperty('treeBaseDuration'); + + if (isRoot) { + pushOperation(TREE_OPERATION_ADD); + pushOperation(id); + pushOperation(ElementTypeRoot); + pushOperation(isProfilingSupported ? 1 : 0); + pushOperation(hasOwnerMetadata ? 1 : 0); + + if (isProfiling) { + if (displayNamesByRootID !== null) { + displayNamesByRootID.set(id, getDisplayNameForRoot(fiber)); + } + } + } else { + const { key } = fiber; + const displayName = getDisplayNameForFiber(fiber); + const elementType = getElementTypeForFiber(fiber); + const { _debugOwner } = fiber; + + const ownerID = + _debugOwner != null ? getFiberID(getPrimaryFiber(_debugOwner)) : 0; + const parentID = parentFiber + ? getFiberID(getPrimaryFiber(parentFiber)) + : 0; + + let displayNameStringID = getStringID(displayName); + let keyStringID = getStringID(key); + pushOperation(TREE_OPERATION_ADD); + pushOperation(id); + pushOperation(elementType); + pushOperation(parentID); + pushOperation(ownerID); + pushOperation(displayNameStringID); + pushOperation(keyStringID); + } + + if (isProfilingSupported) { + idToRootMap.set(id, currentRootID); + + recordProfilingDurations(fiber); + } + } + + function recordUnmount(fiber: Fiber, isSimulated: boolean) { + if (trackedPathMatchFiber !== null) { + // We're in the process of trying to restore previous selection. + // If this fiber matched but is being unmounted, there's no use trying. + // Reset the state so we don't keep holding onto it. + if ( + fiber === trackedPathMatchFiber || + fiber === trackedPathMatchFiber.alternate + ) { + setTrackedPath(null); + } + } + + const isRoot = fiber.tag === HostRoot; + const primaryFiber = getPrimaryFiber(fiber); + if (!fiberToIDMap.has(primaryFiber)) { + // If we've never seen this Fiber, it might be because + // it is inside a non-current Suspense fragment tree, + // and so the store is not even aware of it. + // In that case we can just ignore it, or otherwise + // there will be errors later on. + primaryFibers.delete(primaryFiber); + // TODO: this is fragile and can obscure actual bugs. + return; + } + const id = getFiberID(primaryFiber); + if (isRoot) { + // Roots must be removed only after all children (pending and simultated) have been removed. + // So we track it separately. + pendingUnmountedRootID = id; + } else if (!shouldFilterFiber(fiber)) { + // To maintain child-first ordering, + // we'll push it into one of these queues, + // and later arrange them in the correct order. + if (isSimulated) { + pendingSimulatedUnmountedIDs.push(id); + } else { + pendingRealUnmountedIDs.push(id); + } + } + fiberToIDMap.delete(primaryFiber); + idToFiberMap.delete(id); + primaryFibers.delete(primaryFiber); + + const isProfilingSupported = fiber.hasOwnProperty('treeBaseDuration'); + if (isProfilingSupported) { + idToRootMap.delete(id); + idToTreeBaseDurationMap.delete(id); + } + } + + function mountFiberRecursively( + fiber: Fiber, + parentFiber: Fiber | null, + traverseSiblings = false + ) { + if (__DEBUG__) { + debug('mountFiberRecursively()', fiber, parentFiber); + } + + // If we have the tree selection from previous reload, try to match this Fiber. + // Also remember whether to do the same for siblings. + const mightSiblingsBeOnTrackedPath = updateTrackedPathStateBeforeMount( + fiber + ); + + const shouldIncludeInTree = !shouldFilterFiber(fiber); + if (shouldIncludeInTree) { + recordMount(fiber, parentFiber); + } + + const isTimedOutSuspense = + fiber.tag === ReactTypeOfWork.SuspenseComponent && + fiber.memoizedState !== null; + + if (isTimedOutSuspense) { + // Special case: if Suspense mounts in a timed-out state, + // get the fallback child from the inner fragment and mount + // it as if it was our own child. Updates handle this too. + const primaryChildFragment = fiber.child; + const fallbackChildFragment = primaryChildFragment + ? primaryChildFragment.sibling + : null; + const fallbackChild = fallbackChildFragment + ? fallbackChildFragment.child + : null; + if (fallbackChild !== null) { + mountFiberRecursively( + fallbackChild, + shouldIncludeInTree ? fiber : parentFiber, + true + ); + } + } else { + if (fiber.child !== null) { + mountFiberRecursively( + fiber.child, + shouldIncludeInTree ? fiber : parentFiber, + true + ); + } + } + + // We're exiting this Fiber now, and entering its siblings. + // If we have selection to restore, we might need to re-activate tracking. + updateTrackedPathStateAfterMount(mightSiblingsBeOnTrackedPath); + + if (traverseSiblings && fiber.sibling !== null) { + mountFiberRecursively(fiber.sibling, parentFiber, true); + } + } + + // We use this to simulate unmounting for Suspense trees + // when we switch from primary to fallback. + function unmountFiberChildrenRecursively(fiber: Fiber) { + if (__DEBUG__) { + debug('unmountFiberChildrenRecursively()', fiber); + } + + // We might meet a nested Suspense on our way. + const isTimedOutSuspense = + fiber.tag === ReactTypeOfWork.SuspenseComponent && + fiber.memoizedState !== null; + + let child = fiber.child; + if (isTimedOutSuspense) { + // If it's showing fallback tree, let's traverse it instead. + const primaryChildFragment = fiber.child; + const fallbackChildFragment = primaryChildFragment + ? primaryChildFragment.sibling + : null; + // Skip over to the real Fiber child. + child = fallbackChildFragment ? fallbackChildFragment.child : null; + } + + while (child !== null) { + // Record simulated unmounts children-first. + // We skip nodes without return because those are real unmounts. + if (child.return !== null) { + unmountFiberChildrenRecursively(child); + recordUnmount(child, true); + } + child = child.sibling; + } + } + + function recordProfilingDurations(fiber: Fiber) { + const id = getFiberID(getPrimaryFiber(fiber)); + const { actualDuration, treeBaseDuration } = fiber; + + idToTreeBaseDurationMap.set(id, fiber.treeBaseDuration || 0); + + if (isProfiling) { + const { alternate } = fiber; + + if ( + alternate == null || + treeBaseDuration !== alternate.treeBaseDuration + ) { + // Tree base duration updates are included in the operations typed array. + // So we have to convert them from milliseconds to microseconds so we can send them as ints. + const treeBaseDuration = Math.floor( + (fiber.treeBaseDuration || 0) * 1000 + ); + pushOperation(TREE_OPERATION_UPDATE_TREE_BASE_DURATION); + pushOperation(id); + pushOperation(treeBaseDuration); + } + + if (alternate == null || didFiberRender(alternate, fiber)) { + if (actualDuration != null) { + // The actual duration reported by React includes time spent working on children. + // This is useful information, but it's also useful to be able to exclude child durations. + // The frontend can't compute this, since the immediate children may have been filtered out. + // So we need to do this on the backend. + // Note that this calculated self duration is not the same thing as the base duration. + // The two are calculated differently (tree duration does not accumulate). + let selfDuration = actualDuration; + let child = fiber.child; + while (child !== null) { + selfDuration -= child.actualDuration || 0; + child = child.sibling; + } + + // If profiling is active, store durations for elements that were rendered during the commit. + // Note that we should do this for any fiber we performed work on, regardless of its actualDuration value. + // In some cases actualDuration might be 0 for fibers we worked on (particularly if we're using Date.now) + // In other cases (e.g. Memo) actualDuration might be greater than 0 even if we "bailed out". + const metadata = ((currentCommitProfilingMetadata: any): CommitProfilingData); + metadata.durations.push(id, actualDuration, selfDuration); + metadata.maxActualDuration = Math.max( + metadata.maxActualDuration, + actualDuration + ); + + if (recordChangeDescriptions) { + const changeDescription = getChangeDescription(alternate, fiber); + if (changeDescription !== null) { + if (metadata.changeDescriptions !== null) { + metadata.changeDescriptions.set(id, changeDescription); + } + } + + updateContextsForFiber(fiber); + } + } + } + } + } + + function recordResetChildren(fiber: Fiber, childSet: Fiber) { + // The frontend only really cares about the displayName, key, and children. + // The first two don't really change, so we are only concerned with the order of children here. + // This is trickier than a simple comparison though, since certain types of fibers are filtered. + const nextChildren: Array = []; + + // This is a naive implimentation that shallowly recurses children. + // We might want to revisit this if it proves to be too inefficient. + let child = childSet; + while (child !== null) { + findReorderedChildrenRecursively(child, nextChildren); + child = child.sibling; + } + + const numChildren = nextChildren.length; + if (numChildren < 2) { + // No need to reorder. + return; + } + pushOperation(TREE_OPERATION_REORDER_CHILDREN); + pushOperation(getFiberID(getPrimaryFiber(fiber))); + pushOperation(numChildren); + for (let i = 0; i < nextChildren.length; i++) { + pushOperation(nextChildren[i]); + } + } + + function findReorderedChildrenRecursively( + fiber: Fiber, + nextChildren: Array + ) { + if (!shouldFilterFiber(fiber)) { + nextChildren.push(getFiberID(getPrimaryFiber(fiber))); + } else { + let child = fiber.child; + while (child !== null) { + findReorderedChildrenRecursively(child, nextChildren); + child = child.sibling; + } + } + } + + // Returns whether closest unfiltered fiber parent needs to reset its child list. + function updateFiberRecursively( + nextFiber: Fiber, + prevFiber: Fiber, + parentFiber: Fiber | null + ): boolean { + if (__DEBUG__) { + debug('updateFiberRecursively()', nextFiber, parentFiber); + } + + if ( + mostRecentlyInspectedElement !== null && + mostRecentlyInspectedElement.id === + getFiberID(getPrimaryFiber(nextFiber)) && + didFiberRender(prevFiber, nextFiber) + ) { + // If this Fiber has updated, clear cached inspected data. + // If it is inspected again, it may need to be re-run to obtain updated hooks values. + hasElementUpdatedSinceLastInspected = true; + } + + const shouldIncludeInTree = !shouldFilterFiber(nextFiber); + const isSuspense = nextFiber.tag === SuspenseComponent; + let shouldResetChildren = false; + // The behavior of timed-out Suspense trees is unique. + // Rather than unmount the timed out content (and possibly lose important state), + // React re-parents this content within a hidden Fragment while the fallback is showing. + // This behavior doesn't need to be observable in the DevTools though. + // It might even result in a bad user experience for e.g. node selection in the Elements panel. + // The easiest fix is to strip out the intermediate Fragment fibers, + // so the Elements panel and Profiler don't need to special case them. + // Suspense components only have a non-null memoizedState if they're timed-out. + const prevDidTimeout = isSuspense && prevFiber.memoizedState !== null; + const nextDidTimeOut = isSuspense && nextFiber.memoizedState !== null; + // The logic below is inspired by the codepaths in updateSuspenseComponent() + // inside ReactFiberBeginWork in the React source code. + if (prevDidTimeout && nextDidTimeOut) { + // Fallback -> Fallback: + // 1. Reconcile fallback set. + const nextFiberChild = nextFiber.child; + const nextFallbackChildSet = nextFiberChild + ? nextFiberChild.sibling + : null; + // Note: We can't use nextFiber.child.sibling.alternate + // because the set is special and alternate may not exist. + const prevFiberChild = prevFiber.child; + const prevFallbackChildSet = prevFiberChild + ? prevFiberChild.sibling + : null; + if ( + nextFallbackChildSet != null && + prevFallbackChildSet != null && + updateFiberRecursively( + nextFallbackChildSet, + prevFallbackChildSet, + nextFiber + ) + ) { + shouldResetChildren = true; + } + } else if (prevDidTimeout && !nextDidTimeOut) { + // Fallback -> Primary: + // 1. Unmount fallback set + // Note: don't emulate fallback unmount because React actually did it. + // 2. Mount primary set + const nextPrimaryChildSet = nextFiber.child; + if (nextPrimaryChildSet !== null) { + mountFiberRecursively(nextPrimaryChildSet, nextFiber, true); + } + shouldResetChildren = true; + } else if (!prevDidTimeout && nextDidTimeOut) { + // Primary -> Fallback: + // 1. Hide primary set + // This is not a real unmount, so it won't get reported by React. + // We need to manually walk the previous tree and record unmounts. + unmountFiberChildrenRecursively(prevFiber); + // 2. Mount fallback set + const nextFiberChild = nextFiber.child; + const nextFallbackChildSet = nextFiberChild + ? nextFiberChild.sibling + : null; + if (nextFallbackChildSet != null) { + mountFiberRecursively(nextFallbackChildSet, nextFiber, true); + shouldResetChildren = true; + } + } else { + // Common case: Primary -> Primary. + // This is the same codepath as for non-Suspense fibers. + if (nextFiber.child !== prevFiber.child) { + // If the first child is different, we need to traverse them. + // Each next child will be either a new child (mount) or an alternate (update). + let nextChild = nextFiber.child; + let prevChildAtSameIndex = prevFiber.child; + while (nextChild) { + // We already know children will be referentially different because + // they are either new mounts or alternates of previous children. + // Schedule updates and mounts depending on whether alternates exist. + // We don't track deletions here because they are reported separately. + if (nextChild.alternate) { + const prevChild = nextChild.alternate; + if ( + updateFiberRecursively( + nextChild, + prevChild, + shouldIncludeInTree ? nextFiber : parentFiber + ) + ) { + // If a nested tree child order changed but it can't handle its own + // child order invalidation (e.g. because it's filtered out like host nodes), + // propagate the need to reset child order upwards to this Fiber. + shouldResetChildren = true; + } + // However we also keep track if the order of the children matches + // the previous order. They are always different referentially, but + // if the instances line up conceptually we'll want to know that. + if (prevChild !== prevChildAtSameIndex) { + shouldResetChildren = true; + } + } else { + mountFiberRecursively( + nextChild, + shouldIncludeInTree ? nextFiber : parentFiber + ); + shouldResetChildren = true; + } + // Try the next child. + nextChild = nextChild.sibling; + // Advance the pointer in the previous list so that we can + // keep comparing if they line up. + if (!shouldResetChildren && prevChildAtSameIndex !== null) { + prevChildAtSameIndex = prevChildAtSameIndex.sibling; + } + } + // If we have no more children, but used to, they don't line up. + if (prevChildAtSameIndex !== null) { + shouldResetChildren = true; + } + } + } + if (shouldIncludeInTree) { + const isProfilingSupported = nextFiber.hasOwnProperty('treeBaseDuration'); + if (isProfilingSupported) { + recordProfilingDurations(nextFiber); + } + } + if (shouldResetChildren) { + // We need to crawl the subtree for closest non-filtered Fibers + // so that we can display them in a flat children set. + if (shouldIncludeInTree) { + // Normally, search for children from the rendered child. + let nextChildSet = nextFiber.child; + if (nextDidTimeOut) { + // Special case: timed-out Suspense renders the fallback set. + const nextFiberChild = nextFiber.child; + nextChildSet = nextFiberChild ? nextFiberChild.sibling : null; + } + if (nextChildSet != null) { + recordResetChildren(nextFiber, nextChildSet); + } + // We've handled the child order change for this Fiber. + // Since it's included, there's no need to invalidate parent child order. + return false; + } else { + // Let the closest unfiltered parent Fiber reset its child order instead. + return true; + } + } else { + return false; + } + } + + function cleanup() { + // We don't patch any methods so there is no cleanup. + } + + function flushInitialOperations() { + const localPendingOperationsQueue = pendingOperationsQueue; + + pendingOperationsQueue = null; + + if ( + localPendingOperationsQueue !== null && + localPendingOperationsQueue.length > 0 + ) { + // We may have already queued up some operations before the frontend connected + // If so, let the frontend know about them. + localPendingOperationsQueue.forEach(operations => { + hook.emit('operations', operations); + }); + } else { + // Before the traversals, remember to start tracking + // our path in case we have selection to restore. + if (trackedPath !== null) { + mightBeOnTrackedPath = true; + } + // If we have not been profiling, then we can just walk the tree and build up its current state as-is. + hook.getFiberRoots(rendererID).forEach(root => { + currentRootID = getFiberID(getPrimaryFiber(root.current)); + setRootPseudoKey(currentRootID, root.current); + + if (isProfiling) { + // If profiling is active, store commit time and duration, and the current interactions. + // The frontend may request this information after profiling has stopped. + currentCommitProfilingMetadata = { + changeDescriptions: recordChangeDescriptions ? new Map() : null, + durations: [], + commitTime: getCurrentTime() - profilingStartTime, + interactions: Array.from(root.memoizedInteractions).map( + (interaction: Interaction) => ({ + ...interaction, + timestamp: interaction.timestamp - profilingStartTime, + }) + ), + maxActualDuration: 0, + priorityLevel: null, + }; + } + + mountFiberRecursively(root.current, null); + flushPendingEvents(root); + currentRootID = -1; + }); + } + } + + function handleCommitFiberUnmount(fiber) { + // This is not recursive. + // We can't traverse fibers after unmounting so instead + // we rely on React telling us about each unmount. + recordUnmount(fiber, false); + } + + function handleCommitFiberRoot(root, priorityLevel) { + const current = root.current; + const alternate = current.alternate; + + currentRootID = getFiberID(getPrimaryFiber(current)); + + // Before the traversals, remember to start tracking + // our path in case we have selection to restore. + if (trackedPath !== null) { + mightBeOnTrackedPath = true; + } + + if (isProfiling) { + // If profiling is active, store commit time and duration, and the current interactions. + // The frontend may request this information after profiling has stopped. + currentCommitProfilingMetadata = { + changeDescriptions: recordChangeDescriptions ? new Map() : null, + durations: [], + commitTime: getCurrentTime() - profilingStartTime, + interactions: Array.from(root.memoizedInteractions).map( + (interaction: Interaction) => ({ + ...interaction, + timestamp: interaction.timestamp - profilingStartTime, + }) + ), + maxActualDuration: 0, + priorityLevel: + priorityLevel == null ? null : formatPriorityLevel(priorityLevel), + }; + } + + if (alternate) { + // TODO: relying on this seems a bit fishy. + const wasMounted = + alternate.memoizedState != null && + alternate.memoizedState.element != null; + const isMounted = + current.memoizedState != null && current.memoizedState.element != null; + if (!wasMounted && isMounted) { + // Mount a new root. + setRootPseudoKey(currentRootID, current); + mountFiberRecursively(current, null); + } else if (wasMounted && isMounted) { + // Update an existing root. + updateFiberRecursively(current, alternate, null); + } else if (wasMounted && !isMounted) { + // Unmount an existing root. + removeRootPseudoKey(currentRootID); + recordUnmount(current, false); + } + } else { + // Mount a new root. + setRootPseudoKey(currentRootID, current); + mountFiberRecursively(current, null); + } + + if (isProfiling) { + const commitProfilingMetadata = ((rootToCommitProfilingMetadataMap: any): CommitProfilingMetadataMap).get( + currentRootID + ); + if (commitProfilingMetadata != null) { + commitProfilingMetadata.push( + ((currentCommitProfilingMetadata: any): CommitProfilingData) + ); + } else { + ((rootToCommitProfilingMetadataMap: any): CommitProfilingMetadataMap).set( + currentRootID, + [((currentCommitProfilingMetadata: any): CommitProfilingData)] + ); + } + } + + // We're done here. + flushPendingEvents(root); + + currentRootID = -1; + } + + function findAllCurrentHostFibers(id: number): $ReadOnlyArray { + const fibers = []; + const fiber = findCurrentFiberUsingSlowPathById(id); + if (!fiber) { + return fibers; + } + + // Next we'll drill down this component to find all HostComponent/Text. + let node: Fiber = fiber; + while (true) { + if (node.tag === HostComponent || node.tag === HostText) { + fibers.push(node); + } else if (node.child) { + node.child.return = node; + node = node.child; + continue; + } + if (node === fiber) { + return fibers; + } + while (!node.sibling) { + if (!node.return || node.return === fiber) { + return fibers; + } + node = node.return; + } + node.sibling.return = node.return; + node = node.sibling; + } + // Flow needs the return here, but ESLint complains about it. + // eslint-disable-next-line no-unreachable + return fibers; + } + + function findNativeNodesForFiberID(id: number) { + try { + let fiber = findCurrentFiberUsingSlowPathById(id); + if (fiber === null) { + return null; + } + // Special case for a timed-out Suspense. + const isTimedOutSuspense = + fiber.tag === SuspenseComponent && fiber.memoizedState !== null; + if (isTimedOutSuspense) { + // A timed-out Suspense's findDOMNode is useless. + // Try our best to find the fallback directly. + const maybeFallbackFiber = fiber.child && fiber.child.sibling; + if (maybeFallbackFiber != null) { + fiber = maybeFallbackFiber; + } + } + const hostFibers = findAllCurrentHostFibers(id); + return hostFibers.map(hostFiber => hostFiber.stateNode).filter(Boolean); + } catch (err) { + // The fiber might have unmounted by now. + return null; + } + } + + function getFiberIDForNative( + hostInstance, + findNearestUnfilteredAncestor = false + ) { + let fiber = renderer.findFiberByHostInstance(hostInstance); + if (fiber != null) { + if (findNearestUnfilteredAncestor) { + while (fiber !== null && shouldFilterFiber(fiber)) { + fiber = fiber.return; + } + } + return getFiberID(getPrimaryFiber(((fiber: any): Fiber))); + } + return null; + } + + const MOUNTING = 1; + const MOUNTED = 2; + const UNMOUNTED = 3; + + // This function is copied from React and should be kept in sync: + // https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberTreeReflection.js + function isFiberMountedImpl(fiber: Fiber): number { + let node = fiber; + if (!fiber.alternate) { + // If there is no alternate, this might be a new tree that isn't inserted + // yet. If it is, then it will have a pending insertion effect on it. + if ((node.effectTag & Placement) !== NoEffect) { + return MOUNTING; + } + while (node.return) { + node = node.return; + if ((node.effectTag & Placement) !== NoEffect) { + return MOUNTING; + } + } + } else { + while (node.return) { + node = node.return; + } + } + if (node.tag === HostRoot) { + // TODO: Check if this was a nested HostRoot when used with + // renderContainerIntoSubtree. + return MOUNTED; + } + // If we didn't hit the root, that means that we're in an disconnected tree + // that has been unmounted. + return UNMOUNTED; + } + + // This function is copied from React and should be kept in sync: + // https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberTreeReflection.js + // It would be nice if we updated React to inject this function directly (vs just indirectly via findDOMNode). + // BEGIN copied code + function findCurrentFiberUsingSlowPathById(id: number): Fiber | null { + const fiber = idToFiberMap.get(id); + if (fiber == null) { + console.warn(`Could not find Fiber with id "${id}"`); + return null; + } + + let alternate = fiber.alternate; + if (!alternate) { + // If there is no alternate, then we only need to check if it is mounted. + const state = isFiberMountedImpl(fiber); + if (state === UNMOUNTED) { + throw Error('Unable to find node on an unmounted component.'); + } + if (state === MOUNTING) { + return null; + } + return fiber; + } + // If we have two possible branches, we'll walk backwards up to the root + // to see what path the root points to. On the way we may hit one of the + // special cases and we'll deal with them. + let a: Fiber = fiber; + let b: Fiber = alternate; + while (true) { + let parentA = a.return; + if (parentA === null) { + // We're at the root. + break; + } + let parentB = parentA.alternate; + if (parentB === null) { + // There is no alternate. This is an unusual case. Currently, it only + // happens when a Suspense component is hidden. An extra fragment fiber + // is inserted in between the Suspense fiber and its children. Skip + // over this extra fragment fiber and proceed to the next parent. + const nextParent = parentA.return; + if (nextParent !== null) { + a = b = nextParent; + continue; + } + // If there's no parent, we're at the root. + break; + } + + // If both copies of the parent fiber point to the same child, we can + // assume that the child is current. This happens when we bailout on low + // priority: the bailed out fiber's child reuses the current child. + if (parentA.child === parentB.child) { + let child = parentA.child; + while (child) { + if (child === a) { + // We've determined that A is the current branch. + if (isFiberMountedImpl(parentA) !== MOUNTED) { + throw Error('Unable to find node on an unmounted component.'); + } + return fiber; + } + if (child === b) { + // We've determined that B is the current branch. + if (isFiberMountedImpl(parentA) !== MOUNTED) { + throw Error('Unable to find node on an unmounted component.'); + } + return alternate; + } + child = child.sibling; + } + // We should never have an alternate for any mounting node. So the only + // way this could possibly happen is if this was unmounted, if at all. + throw Error('Unable to find node on an unmounted component.'); + } + + if (a.return !== b.return) { + // The return pointer of A and the return pointer of B point to different + // fibers. We assume that return pointers never criss-cross, so A must + // belong to the child set of A.return, and B must belong to the child + // set of B.return. + a = parentA; + b = parentB; + } else { + // The return pointers point to the same fiber. We'll have to use the + // default, slow path: scan the child sets of each parent alternate to see + // which child belongs to which set. + // + // Search parent A's child set + let didFindChild = false; + let child = parentA.child; + while (child) { + if (child === a) { + didFindChild = true; + a = parentA; + b = parentB; + break; + } + if (child === b) { + didFindChild = true; + b = parentA; + a = parentB; + break; + } + child = child.sibling; + } + if (!didFindChild) { + // Search parent B's child set + child = parentB.child; + while (child) { + if (child === a) { + didFindChild = true; + a = parentB; + b = parentA; + break; + } + if (child === b) { + didFindChild = true; + b = parentB; + a = parentA; + break; + } + child = child.sibling; + } + if (!didFindChild) { + throw Error( + 'Child was not found in either parent set. This indicates a bug ' + + 'in React related to the return pointer. Please file an issue.' + ); + } + } + } + + if (a.alternate !== b) { + throw Error( + "Return fibers should always be each others' alternates. " + + 'This error is likely caused by a bug in React. Please file an issue.' + ); + } + } + // If the root is not a host container, we're in a disconnected tree. I.e. + // unmounted. + if (a.tag !== HostRoot) { + throw Error('Unable to find node on an unmounted component.'); + } + if (a.stateNode.current === a) { + // We've determined that A is the current branch. + return fiber; + } + // Otherwise B has to be current branch. + return alternate; + } + // END copied code + + function prepareViewElementSource(id: number): void { + let fiber = idToFiberMap.get(id); + if (fiber == null) { + console.warn(`Could not find Fiber with id "${id}"`); + return; + } + + const { elementType, tag, type } = fiber; + + switch (tag) { + case ClassComponent: + case IncompleteClassComponent: + case IndeterminateComponent: + case FunctionComponent: + global.$type = type; + break; + case ForwardRef: + global.$type = type.render; + break; + case MemoComponent: + case SimpleMemoComponent: + global.$type = + elementType != null && elementType.type != null + ? elementType.type + : type; + break; + default: + global.$type = null; + break; + } + } + + function getOwnersList(id: number): Array | null { + let fiber = findCurrentFiberUsingSlowPathById(id); + if (fiber == null) { + return null; + } + + const { _debugOwner } = fiber; + + const owners = [ + { + displayName: getDisplayNameForFiber(fiber) || 'Anonymous', + id, + type: getElementTypeForFiber(fiber), + }, + ]; + + if (_debugOwner) { + let owner = _debugOwner; + while (owner !== null) { + owners.unshift({ + displayName: getDisplayNameForFiber(owner) || 'Anonymous', + id: getFiberID(getPrimaryFiber(owner)), + type: getElementTypeForFiber(owner), + }); + owner = owner._debugOwner || null; + } + } + + return owners; + } + + // Fast path props lookup for React Native style editor. + // Could use inspectElementRaw() but that would require shallow rendering hooks components, + // and could also mess with memoization. + function getInstanceAndStyle(id: number): InstanceAndStyle { + let instance = null; + let style = null; + + let fiber = findCurrentFiberUsingSlowPathById(id); + if (fiber !== null) { + instance = fiber.stateNode; + style = fiber.memoizedProps.style; + } + + return { instance, style }; + } + + function inspectElementRaw(id: number): InspectedElement | null { + let fiber = findCurrentFiberUsingSlowPathById(id); + if (fiber == null) { + return null; + } + + const { + _debugOwner, + _debugSource, + stateNode, + memoizedProps, + memoizedState, + tag, + type, + } = fiber; + + const usesHooks = + (tag === FunctionComponent || + tag === SimpleMemoComponent || + tag === ForwardRef) && + !!memoizedState; + + const typeSymbol = getTypeSymbol(type); + + let canViewSource = false; + let context = null; + if ( + tag === ClassComponent || + tag === FunctionComponent || + tag === IncompleteClassComponent || + tag === IndeterminateComponent || + tag === MemoComponent || + tag === ForwardRef || + tag === SimpleMemoComponent + ) { + canViewSource = true; + if (stateNode && stateNode.context != null) { + context = stateNode.context; + } + } else if ( + typeSymbol === CONTEXT_CONSUMER_NUMBER || + typeSymbol === CONTEXT_CONSUMER_SYMBOL_STRING + ) { + // 16.3-16.5 read from "type" because the Consumer is the actual context object. + // 16.6+ should read from "type._context" because Consumer can be different (in DEV). + // NOTE Keep in sync with getDisplayNameForFiber() + const consumerResolvedContext = type._context || type; + + // Global context value. + context = consumerResolvedContext._currentValue || null; + + // Look for overridden value. + let current = ((fiber: any): Fiber).return; + while (current !== null) { + const currentType = current.type; + const currentTypeSymbol = getTypeSymbol(currentType); + if ( + currentTypeSymbol === CONTEXT_PROVIDER_NUMBER || + currentTypeSymbol === CONTEXT_PROVIDER_SYMBOL_STRING + ) { + // 16.3.0 exposed the context object as "context" + // PR #12501 changed it to "_context" for 16.3.1+ + // NOTE Keep in sync with getDisplayNameForFiber() + const providerResolvedContext = + currentType._context || currentType.context; + if (providerResolvedContext === consumerResolvedContext) { + context = current.memoizedProps.value; + break; + } + } + + current = current.return; + } + } + + if (context !== null) { + // To simplify hydration and display logic for context, wrap in a value object. + // Otherwise simple values (e.g. strings, booleans) become harder to handle. + context = { value: context }; + } + + let owners = null; + if (_debugOwner) { + owners = []; + let owner = _debugOwner; + while (owner !== null) { + owners.push({ + displayName: getDisplayNameForFiber(owner) || 'Anonymous', + id: getFiberID(getPrimaryFiber(owner)), + type: getElementTypeForFiber(owner), + }); + owner = owner._debugOwner || null; + } + } + + const isTimedOutSuspense = + tag === SuspenseComponent && memoizedState !== null; + + let hooks = null; + if (usesHooks) { + const originalConsoleMethods = {}; + + // Temporarily disable all console logging before re-running the hook. + for (let method in console) { + try { + originalConsoleMethods[method] = console[method]; + // $FlowFixMe property error|warn is not writable. + console[method] = () => {}; + } catch (error) {} + } + + try { + hooks = inspectHooksOfFiber( + fiber, + (renderer.currentDispatcherRef: any) + ); + } finally { + // Restore original console functionality. + for (let method in originalConsoleMethods) { + try { + // $FlowFixMe property error|warn is not writable. + console[method] = originalConsoleMethods[method]; + } catch (error) {} + } + } + } + + return { + id, + + // Does the current renderer support editable hooks? + canEditHooks: typeof overrideHookState === 'function', + + // Does the current renderer support editable function props? + canEditFunctionProps: typeof overrideProps === 'function', + + canToggleSuspense: + supportsTogglingSuspense && + // If it's showing the real content, we can always flip fallback. + (!isTimedOutSuspense || + // If it's showing fallback because we previously forced it to, + // allow toggling it back to remove the fallback override. + forceFallbackForSuspenseIDs.has(id)), + + // Can view component source location. + canViewSource, + + displayName: getDisplayNameForFiber(fiber), + type: getElementTypeForFiber(fiber), + + // Inspectable properties. + // TODO Review sanitization approach for the below inspectable values. + context, + hooks, + props: memoizedProps, + state: usesHooks ? null : memoizedState, + + // List of owners + owners, + + // Location of component in source coude. + source: _debugSource || null, + }; + } + + let mostRecentlyInspectedElement: InspectedElement | null = null; + let hasElementUpdatedSinceLastInspected: boolean = false; + let currentlyInspectedPaths: Object = {}; + + function isMostRecentlyInspectedElementCurrent(id: number): boolean { + return ( + mostRecentlyInspectedElement !== null && + mostRecentlyInspectedElement.id === id && + !hasElementUpdatedSinceLastInspected + ); + } + + // 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 createIsPathWhitelisted( + key: string | null, + secondaryCategory: 'hooks' | null + ) { + // 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 isPathWhitelisted(path: Array): boolean { + switch (secondaryCategory) { + case 'hooks': + if (path.length === 1) { + // Never dehydrate the "hooks" object at the top levels. + return true; + } + if ( + path[path.length - 1] === 'subHooks' || + path[path.length - 2] === 'subHooks' + ) { + // Dehydrating the 'subHooks' property makes the HooksTree UI a lot more complicated, + // so it's easiest for now if we just don't break on this boundary. + // We can always dehydrate a level deeper (in the value object). + return true; + } + break; + default: + break; + } + + let current = + key === null ? currentlyInspectedPaths : currentlyInspectedPaths[key]; + if (!current) { + return false; + } + for (let i = 0; i < path.length; i++) { + current = current[path[i]]; + if (!current) { + return false; + } + } + return true; + }; + } + + function updateSelectedElement(inspectedElement: InspectedElement): void { + const { hooks, id, props } = inspectedElement; + + let fiber = idToFiberMap.get(id); + if (fiber == null) { + console.warn(`Could not find Fiber with id "${id}"`); + return; + } + + const { elementType, stateNode, tag, type } = fiber; + + switch (tag) { + case ClassComponent: + case IncompleteClassComponent: + case IndeterminateComponent: + global.$r = stateNode; + break; + case FunctionComponent: + global.$r = { + hooks, + props, + type, + }; + break; + case ForwardRef: + global.$r = { + props, + type: type.render, + }; + break; + case MemoComponent: + case SimpleMemoComponent: + global.$r = { + props, + type: + elementType != null && elementType.type != null + ? elementType.type + : type, + }; + break; + default: + global.$r = null; + break; + } + } + + function inspectElement( + id: number, + path?: Array + ): InspectedElementPayload { + const isCurrent = 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 + ), + createIsPathWhitelisted(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 dehyration (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, + createIsPathWhitelisted('context', null) + ); + cleanedInspectedElement.hooks = cleanForBridge( + cleanedInspectedElement.hooks, + createIsPathWhitelisted('hooks', 'hooks') + ); + cleanedInspectedElement.props = cleanForBridge( + cleanedInspectedElement.props, + createIsPathWhitelisted('props', null) + ); + cleanedInspectedElement.state = cleanForBridge( + cleanedInspectedElement.state, + createIsPathWhitelisted('state', null) + ); + + return { + id, + type: 'full-data', + value: cleanedInspectedElement, + }; + } + } + + function logElementToConsole(id) { + const result = isMostRecentlyInspectedElementCurrent(id) + ? mostRecentlyInspectedElement + : inspectElementRaw(id); + if (result === null) { + console.warn(`Could not find Fiber with id "${id}"`); + return; + } + + const supportsGroup = typeof console.groupCollapsed === 'function'; + if (supportsGroup) { + console.groupCollapsed( + `[Click to expand] %c<${result.displayName || 'Component'} />`, + // --dom-tag-name-color is the CSS variable Chrome styles HTML elements with in the console. + 'color: var(--dom-tag-name-color); font-weight: normal;' + ); + } + if (result.props !== null) { + console.log('Props:', result.props); + } + if (result.state !== null) { + console.log('State:', result.state); + } + if (result.hooks !== null) { + console.log('Hooks:', result.hooks); + } + const nativeNodes = findNativeNodesForFiberID(id); + if (nativeNodes !== null) { + console.log('Nodes:', nativeNodes); + } + if (result.source !== null) { + console.log('Location:', result.source); + } + if (window.chrome || /firefox/i.test(navigator.userAgent)) { + console.log( + 'Right-click any value to save it as a global variable for further inspection.' + ); + } + if (supportsGroup) { + console.groupEnd(); + } + } + + function setInHook( + id: number, + index: number, + path: Array, + value: any + ) { + const fiber = findCurrentFiberUsingSlowPathById(id); + if (fiber !== null) { + if (typeof overrideHookState === 'function') { + overrideHookState(fiber, index, path, value); + } + } + } + + function setInProps(id: number, path: Array, value: any) { + const fiber = findCurrentFiberUsingSlowPathById(id); + if (fiber !== null) { + const instance = fiber.stateNode; + if (instance === null) { + if (typeof overrideProps === 'function') { + overrideProps(fiber, path, value); + } + } else { + fiber.pendingProps = copyWithSet(instance.props, path, value); + instance.forceUpdate(); + } + } + } + + function setInState(id: number, path: Array, value: any) { + const fiber = findCurrentFiberUsingSlowPathById(id); + if (fiber !== null) { + const instance = fiber.stateNode; + setInObject(instance.state, path, value); + instance.forceUpdate(); + } + } + + function setInContext(id: number, path: Array, value: any) { + // To simplify hydration and display of primative context values (e.g. number, string) + // the inspectElement() method wraps context in a {value: ...} object. + // We need to remove the first part of the path (the "value") before continuing. + path = path.slice(1); + + const fiber = findCurrentFiberUsingSlowPathById(id); + if (fiber !== null) { + const instance = fiber.stateNode; + if (path.length === 0) { + // Simple context value + instance.context = value; + } else { + setInObject(instance.context, path, value); + } + instance.forceUpdate(); + } + } + + type CommitProfilingData = {| + changeDescriptions: Map | null, + commitTime: number, + durations: Array, + interactions: Array, + maxActualDuration: number, + priorityLevel: string | null, + |}; + + type CommitProfilingMetadataMap = Map>; + type DisplayNamesByRootID = Map; + + let currentCommitProfilingMetadata: CommitProfilingData | null = null; + let displayNamesByRootID: DisplayNamesByRootID | null = null; + let idToContextsMap: Map | null = null; + let initialTreeBaseDurationsMap: Map | null = null; + let initialIDToRootMap: Map | null = null; + let isProfiling: boolean = false; + let profilingStartTime: number = 0; + let recordChangeDescriptions: boolean = false; + let rootToCommitProfilingMetadataMap: CommitProfilingMetadataMap | null = null; + + function getProfilingData(): ProfilingDataBackend { + const dataForRoots: Array = []; + + if (rootToCommitProfilingMetadataMap === null) { + throw Error( + 'getProfilingData() called before any profiling data was recorded' + ); + } + + rootToCommitProfilingMetadataMap.forEach( + (commitProfilingMetadata, rootID) => { + const commitData: Array = []; + const initialTreeBaseDurations: Array<[number, number]> = []; + const allInteractions: Map = new Map(); + const interactionCommits: Map> = new Map(); + + const displayName = + (displayNamesByRootID !== null && displayNamesByRootID.get(rootID)) || + 'Unknown'; + + if (initialTreeBaseDurationsMap != null) { + initialTreeBaseDurationsMap.forEach((treeBaseDuration, id) => { + if ( + initialIDToRootMap != null && + initialIDToRootMap.get(id) === rootID + ) { + // We don't need to convert milliseconds to microseconds in this case, + // because the profiling summary is JSON serialized. + initialTreeBaseDurations.push([id, treeBaseDuration]); + } + }); + } + + commitProfilingMetadata.forEach((commitProfilingData, commitIndex) => { + const { + changeDescriptions, + durations, + interactions, + maxActualDuration, + priorityLevel, + commitTime, + } = commitProfilingData; + + const interactionIDs: Array = []; + + interactions.forEach(interaction => { + if (!allInteractions.has(interaction.id)) { + allInteractions.set(interaction.id, interaction); + } + + interactionIDs.push(interaction.id); + + const commitIndices = interactionCommits.get(interaction.id); + if (commitIndices != null) { + commitIndices.push(commitIndex); + } else { + interactionCommits.set(interaction.id, [commitIndex]); + } + }); + + const fiberActualDurations: Array<[number, number]> = []; + const fiberSelfDurations: Array<[number, number]> = []; + for (let i = 0; i < durations.length; i += 3) { + const fiberID = durations[i]; + fiberActualDurations.push([fiberID, durations[i + 1]]); + fiberSelfDurations.push([fiberID, durations[i + 2]]); + } + + commitData.push({ + changeDescriptions: + changeDescriptions !== null + ? Array.from(changeDescriptions.entries()) + : null, + duration: maxActualDuration, + fiberActualDurations, + fiberSelfDurations, + interactionIDs, + priorityLevel, + timestamp: commitTime, + }); + }); + + dataForRoots.push({ + commitData, + displayName, + initialTreeBaseDurations, + interactionCommits: Array.from(interactionCommits.entries()), + interactions: Array.from(allInteractions.entries()), + rootID, + }); + } + ); + + return { + dataForRoots, + rendererID, + }; + } + + function startProfiling(shouldRecordChangeDescriptions: boolean) { + if (isProfiling) { + return; + } + + recordChangeDescriptions = shouldRecordChangeDescriptions; + + // Capture initial values as of the time profiling starts. + // It's important we snapshot both the durations and the id-to-root map, + // since either of these may change during the profiling session + // (e.g. when a fiber is re-rendered or when a fiber gets removed). + displayNamesByRootID = new Map(); + initialTreeBaseDurationsMap = new Map(idToTreeBaseDurationMap); + initialIDToRootMap = new Map(idToRootMap); + idToContextsMap = new Map(); + + hook.getFiberRoots(rendererID).forEach(root => { + const rootID = getFiberID(getPrimaryFiber(root.current)); + ((displayNamesByRootID: any): DisplayNamesByRootID).set( + rootID, + getDisplayNameForRoot(root.current) + ); + + if (shouldRecordChangeDescriptions) { + // Record all contexts at the time profiling is started. + // Fibers only store the current context value, + // so we need to track them separatenly in order to determine changed keys. + crawlToInitializeContextsMap(root.current); + } + }); + + isProfiling = true; + profilingStartTime = getCurrentTime(); + rootToCommitProfilingMetadataMap = new Map(); + } + + function stopProfiling() { + isProfiling = false; + recordChangeDescriptions = false; + } + + // Automatically start profiling so that we don't miss timing info from initial "mount". + if ( + sessionStorageGetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY) === 'true' + ) { + startProfiling( + sessionStorageGetItem(SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY) === + 'true' + ); + } + + // React will switch between these implementations depending on whether + // we have any manually suspended Fibers or not. + + function shouldSuspendFiberAlwaysFalse() { + return false; + } + + let forceFallbackForSuspenseIDs = new Set(); + function shouldSuspendFiberAccordingToSet(fiber) { + const id = getFiberID(getPrimaryFiber(((fiber: any): Fiber))); + return forceFallbackForSuspenseIDs.has(id); + } + + function overrideSuspense(id, forceFallback) { + if ( + typeof setSuspenseHandler !== 'function' || + typeof scheduleUpdate !== 'function' + ) { + throw new Error( + 'Expected overrideSuspense() to not get called for earlier React versions.' + ); + } + if (forceFallback) { + forceFallbackForSuspenseIDs.add(id); + if (forceFallbackForSuspenseIDs.size === 1) { + // First override is added. Switch React to slower path. + setSuspenseHandler(shouldSuspendFiberAccordingToSet); + } + } else { + forceFallbackForSuspenseIDs.delete(id); + if (forceFallbackForSuspenseIDs.size === 0) { + // Last override is gone. Switch React back to fast path. + setSuspenseHandler(shouldSuspendFiberAlwaysFalse); + } + } + const fiber = idToFiberMap.get(id); + scheduleUpdate(fiber); + } + + // Remember if we're trying to restore the selection after reload. + // In that case, we'll do some extra checks for matching mounts. + let trackedPath: Array | null = null; + let trackedPathMatchFiber: Fiber | null = null; + let trackedPathMatchDepth = -1; + let mightBeOnTrackedPath = false; + + function setTrackedPath(path: Array | null) { + if (path === null) { + trackedPathMatchFiber = null; + trackedPathMatchDepth = -1; + mightBeOnTrackedPath = false; + } + trackedPath = path; + } + + // We call this before traversing a new mount. + // It remembers whether this Fiber is the next best match for tracked path. + // The return value signals whether we should keep matching siblings or not. + function updateTrackedPathStateBeforeMount(fiber: Fiber): boolean { + if (trackedPath === null || !mightBeOnTrackedPath) { + // Fast path: there's nothing to track so do nothing and ignore siblings. + return false; + } + const returnFiber = fiber.return; + const returnAlternate = returnFiber !== null ? returnFiber.alternate : null; + // By now we know there's some selection to restore, and this is a new Fiber. + // Is this newly mounted Fiber a direct child of the current best match? + // (This will also be true for new roots if we haven't matched anything yet.) + if ( + trackedPathMatchFiber === returnFiber || + (trackedPathMatchFiber === returnAlternate && returnAlternate !== null) + ) { + // Is this the next Fiber we should select? Let's compare the frames. + const actualFrame = getPathFrame(fiber); + const expectedFrame = trackedPath[trackedPathMatchDepth + 1]; + if (expectedFrame === undefined) { + throw new Error('Expected to see a frame at the next depth.'); + } + if ( + actualFrame.index === expectedFrame.index && + actualFrame.key === expectedFrame.key && + actualFrame.displayName === expectedFrame.displayName + ) { + // We have our next match. + trackedPathMatchFiber = fiber; + trackedPathMatchDepth++; + // Are we out of frames to match? + if (trackedPathMatchDepth === trackedPath.length - 1) { + // There's nothing that can possibly match afterwards. + // Don't check the children. + mightBeOnTrackedPath = false; + } else { + // Check the children, as they might reveal the next match. + mightBeOnTrackedPath = true; + } + // In either case, since we have a match, we don't need + // to check the siblings. They'll never match. + return false; + } + } + // This Fiber's parent is on the path, but this Fiber itself isn't. + // There's no need to check its children--they won't be on the path either. + mightBeOnTrackedPath = false; + // However, one of its siblings may be on the path so keep searching. + return true; + } + + function updateTrackedPathStateAfterMount(mightSiblingsBeOnTrackedPath) { + // updateTrackedPathStateBeforeMount() told us whether to match siblings. + // Now that we're entering siblings, let's use that information. + mightBeOnTrackedPath = mightSiblingsBeOnTrackedPath; + } + + // Roots don't have a real persistent identity. + // A root's "pseudo key" is "childDisplayName:indexWithThatName". + // For example, "App:0" or, in case of similar roots, "Story:0", "Story:1", etc. + // We will use this to try to disambiguate roots when restoring selection between reloads. + const rootPseudoKeys: Map = new Map(); + const rootDisplayNameCounter: Map = new Map(); + + function setRootPseudoKey(id: number, fiber: Fiber) { + const name = getDisplayNameForRoot(fiber); + const counter = rootDisplayNameCounter.get(name) || 0; + rootDisplayNameCounter.set(name, counter + 1); + const pseudoKey = `${name}:${counter}`; + rootPseudoKeys.set(id, pseudoKey); + } + + function removeRootPseudoKey(id: number) { + const pseudoKey = rootPseudoKeys.get(id); + if (pseudoKey === undefined) { + throw new Error('Expected root pseudo key to be known.'); + } + const name = pseudoKey.substring(0, pseudoKey.lastIndexOf(':')); + const counter = rootDisplayNameCounter.get(name); + if (counter === undefined) { + throw new Error('Expected counter to be known.'); + } + if (counter > 1) { + rootDisplayNameCounter.set(name, counter - 1); + } else { + rootDisplayNameCounter.delete(name); + } + rootPseudoKeys.delete(id); + } + + function getDisplayNameForRoot(fiber: Fiber): string { + let preferredDisplayName = null; + let fallbackDisplayName = null; + let child = fiber.child; + // Go at most three levels deep into direct children + // while searching for a child that has a displayName. + for (let i = 0; i < 3; i++) { + if (child === null) { + break; + } + const displayName = getDisplayNameForFiber(child); + if (displayName !== null) { + // Prefer display names that we get from user-defined components. + // We want to avoid using e.g. 'Suspense' unless we find nothing else. + if (typeof child.type === 'function') { + // There's a few user-defined tags, but we'll prefer the ones + // that are usually explicitly named (function or class components). + preferredDisplayName = displayName; + } else if (fallbackDisplayName === null) { + fallbackDisplayName = displayName; + } + } + if (preferredDisplayName !== null) { + break; + } + child = child.child; + } + return preferredDisplayName || fallbackDisplayName || 'Anonymous'; + } + + function getPathFrame(fiber: Fiber): PathFrame { + const { key } = fiber; + let displayName = getDisplayNameForFiber(fiber); + const index = fiber.index; + switch (fiber.tag) { + case HostRoot: + // Roots don't have a real displayName, index, or key. + // Instead, we'll use the pseudo key (childDisplayName:indexWithThatName). + const id = getFiberID(getPrimaryFiber(fiber)); + const pseudoKey = rootPseudoKeys.get(id); + if (pseudoKey === undefined) { + throw new Error('Expected mounted root to have known pseudo key.'); + } + displayName = pseudoKey; + break; + case HostComponent: + displayName = fiber.type; + break; + default: + break; + } + return { + displayName, + key, + index, + }; + } + + // Produces a serializable representation that does a best effort + // of identifying a particular Fiber between page reloads. + // The return path will contain Fibers that are "invisible" to the store + // because their keys and indexes are important to restoring the selection. + function getPathForElement(id: number): Array | null { + let fiber = idToFiberMap.get(id); + if (fiber == null) { + return null; + } + const keyPath = []; + while (fiber !== null) { + keyPath.push(getPathFrame(fiber)); + fiber = fiber.return; + } + keyPath.reverse(); + return keyPath; + } + + function getBestMatchForTrackedPath(): PathMatch | null { + if (trackedPath === null) { + // Nothing to match. + return null; + } + if (trackedPathMatchFiber === null) { + // We didn't find anything. + return null; + } + // Find the closest Fiber store is aware of. + let fiber = trackedPathMatchFiber; + while (fiber !== null && shouldFilterFiber(fiber)) { + fiber = fiber.return; + } + if (fiber === null) { + return null; + } + return { + id: getFiberID(getPrimaryFiber(fiber)), + isFullMatch: trackedPathMatchDepth === trackedPath.length - 1, + }; + } + + const formatPriorityLevel = (priorityLevel: ?number) => { + if (priorityLevel == null) { + return 'Unknown'; + } + + switch (priorityLevel) { + case ImmediatePriority: + return 'Immediate'; + case UserBlockingPriority: + return 'User-Blocking'; + case NormalPriority: + return 'Normal'; + case LowPriority: + return 'Low'; + case IdlePriority: + return 'Idle'; + case NoPriority: + default: + return 'Unknown'; + } + }; + + return { + cleanup, + findNativeNodesForFiberID, + flushInitialOperations, + getBestMatchForTrackedPath, + getFiberIDForNative, + getInstanceAndStyle, + getOwnersList, + getPathForElement, + getProfilingData, + handleCommitFiberRoot, + handleCommitFiberUnmount, + inspectElement, + logElementToConsole, + prepareViewElementSource, + overrideSuspense, + renderer, + setInContext, + setInHook, + setInProps, + setInState, + setTrackedPath, + startProfiling, + stopProfiling, + updateComponentFilters, + }; +} diff --git a/extension/src/backend/types.js b/extension/src/backend/types.js new file mode 100644 index 0000000000000..75cd1e11f5a64 --- /dev/null +++ b/extension/src/backend/types.js @@ -0,0 +1,377 @@ +// @flow + +import type { ComponentFilter, ElementType } from 'src/types'; +import type { Interaction } from 'src/devtools/views/Profiler/types'; +import type { ResolveNativeStyle } from 'src/backend/NativeStyleEditor/setupNativeStyleEditor'; + +type BundleType = + | 0 // PROD + | 1; // DEV + +export type WorkTag = number; +export type SideEffectTag = number; +export type ExpirationTime = number; +export type RefObject = {| + current: any, +|}; +export type Source = {| + fileName: string, + lineNumber: number, +|}; +export type HookType = + | 'useState' + | 'useReducer' + | 'useContext' + | 'useRef' + | 'useEffect' + | 'useLayoutEffect' + | 'useCallback' + | 'useMemo' + | 'useImperativeHandle' + | 'useDebugValue'; + +// The Fiber type is copied from React and should be kept in sync: +// https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiber.js +// The properties we don't use in DevTools are omitted. +export type Fiber = {| + tag: WorkTag, + + key: null | string, + + elementType: any, + + type: any, + + stateNode: any, + + return: Fiber | null, + + child: Fiber | null, + sibling: Fiber | null, + index: number, + + ref: null | (((handle: mixed) => void) & { _stringRef: ?string }) | RefObject, + + pendingProps: any, // This type will be more specific once we overload the tag. + memoizedProps: any, // The props used to create the output. + + memoizedState: any, + + effectTag: SideEffectTag, + + alternate: Fiber | null, + + actualDuration?: number, + + actualStartTime?: number, + + treeBaseDuration?: number, + + _debugSource?: Source | null, + _debugOwner?: Fiber | null, +|}; + +// TODO: If it's useful for the frontend to know which types of data an Element has +// (e.g. props, state, context, hooks) then we could add a bitmask field for this +// to keep the number of attributes small. +export type FiberData = {| + key: string | null, + displayName: string | null, + type: ElementType, +|}; + +export type NativeType = Object; +export type RendererID = number; + +type Dispatcher = any; + +export type GetFiberIDForNative = ( + component: NativeType, + findNearestUnfilteredAncestor?: boolean +) => number | null; +export type FindNativeNodesForFiberID = (id: number) => ?Array; + +export type ReactProviderType = { + $$typeof: Symbol | number, + _context: ReactContext, +}; + +export type ReactContext = { + $$typeof: Symbol | number, + Consumer: ReactContext, + Provider: ReactProviderType, + + _calculateChangedBits: ((a: T, b: T) => number) | null, + + _currentValue: T, + _currentValue2: T, + _threadCount: number, + + // DEV only + _currentRenderer?: Object | null, + _currentRenderer2?: Object | null, +}; + +export type ReactRenderer = { + findFiberByHostInstance: (hostInstance: NativeType) => ?Fiber, + version: string, + bundleType: BundleType, + + // 16.9+ + overrideHookState?: ?( + fiber: Object, + id: number, + path: Array, + value: any + ) => void, + + // 16.7+ + overrideProps?: ?( + fiber: Object, + path: Array, + value: any + ) => void, + + // 16.9+ + scheduleUpdate?: ?(fiber: Object) => void, + setSuspenseHandler?: ?(shouldSuspend: (fiber: Object) => boolean) => void, + + // Only injected by React v16.8+ in order to support hooks inspection. + currentDispatcherRef?: {| current: null | Dispatcher |}, + + // Only injected by React v16.9+ in DEV mode. + // Enables DevTools to append owners-only component stack to error messages. + getCurrentFiber?: () => Fiber | null, + + // <= 15 + Mount?: any, +}; + +export type ChangeDescription = {| + context: Array | boolean | null, + didHooksChange: boolean, + isFirstMount: boolean, + props: Array | null, + state: Array | null, +|}; + +export type CommitDataBackend = {| + // Tuple of fiber ID and change description + changeDescriptions: Array<[number, ChangeDescription]> | null, + duration: number, + // Tuple of fiber ID and actual duration + fiberActualDurations: Array<[number, number]>, + // Tuple of fiber ID and computed "self" duration + fiberSelfDurations: Array<[number, number]>, + interactionIDs: Array, + priorityLevel: string | null, + timestamp: number, +|}; + +export type ProfilingDataForRootBackend = {| + commitData: Array, + displayName: string, + // Tuple of Fiber ID and base duration + initialTreeBaseDurations: Array<[number, number]>, + // Tuple of Interaction ID and commit indices + interactionCommits: Array<[number, Array]>, + interactions: Array<[number, Interaction]>, + rootID: number, +|}; + +// Profiling data collected by the renderer interface. +// This information will be passed to the frontend and combined with info it collects. +export type ProfilingDataBackend = {| + dataForRoots: Array, + rendererID: number, +|}; + +export type PathFrame = {| + key: string | null, + index: number, + displayName: string | null, +|}; + +export type PathMatch = {| + id: number, + isFullMatch: boolean, +|}; + +export type Owner = {| + displayName: string | null, + id: number, + type: ElementType, +|}; + +export type OwnersList = {| + id: number, + owners: Array | null, +|}; + +export type InspectedElement = {| + id: number, + + displayName: string | null, + + // Does the current renderer support editable hooks? + canEditHooks: boolean, + + // Does the current renderer support editable function props? + canEditFunctionProps: boolean, + + // Is this Suspense, and can its value be overriden now? + canToggleSuspense: boolean, + + // Can view component source location. + canViewSource: boolean, + + // Inspectable properties. + context: Object | null, + hooks: Object | null, + props: Object | null, + state: Object | null, + + // List of owners + owners: Array | null, + + // Location of component in source coude. + source: Source | null, + + type: ElementType, +|}; + +export const InspectElementFullDataType = 'full-data'; +export const InspectElementNoChangeType = 'no-change'; +export const InspectElementNotFoundType = 'not-found'; +export const InspectElementHydratedPathType = 'hydrated-path'; + +type InspectElementFullData = {| + id: number, + type: 'full-data', + value: InspectedElement, +|}; + +type InspectElementHydratedPath = {| + id: number, + type: 'hydrated-path', + path: Array, + value: any, +|}; + +type InspectElementNoChange = {| + id: number, + type: 'no-change', +|}; + +type InspectElementNotFound = {| + id: number, + type: 'not-found', +|}; + +export type InspectedElementPayload = + | InspectElementFullData + | InspectElementHydratedPath + | InspectElementNoChange + | InspectElementNotFound; + +export type InstanceAndStyle = {| + instance: Object | null, + style: Object | null, +|}; + +export type RendererInterface = { + cleanup: () => void, + findNativeNodesForFiberID: FindNativeNodesForFiberID, + flushInitialOperations: () => void, + getBestMatchForTrackedPath: () => PathMatch | null, + getFiberIDForNative: GetFiberIDForNative, + getInstanceAndStyle(id: number): InstanceAndStyle, + getProfilingData(): ProfilingDataBackend, + getOwnersList: (id: number) => Array | null, + getPathForElement: (id: number) => Array | null, + handleCommitFiberRoot: (fiber: Object, commitPriority?: number) => void, + handleCommitFiberUnmount: (fiber: Object) => void, + inspectElement: ( + id: number, + path?: Array + ) => InspectedElementPayload, + logElementToConsole: (id: number) => void, + overrideSuspense: (id: number, forceFallback: boolean) => void, + prepareViewElementSource: (id: number) => void, + renderer: ReactRenderer | null, + setInContext: (id: number, path: Array, value: any) => void, + setInHook: ( + id: number, + index: number, + path: Array, + value: any + ) => void, + setInProps: (id: number, path: Array, value: any) => void, + setInState: (id: number, path: Array, value: any) => void, + setTrackedPath: (path: Array | null) => void, + startProfiling: (recordChangeDescriptions: boolean) => void, + stopProfiling: () => void, + updateComponentFilters: (somponentFilters: Array) => void, +}; + +export type Handler = (data: any) => void; + +export type DevToolsHook = { + listeners: { [key: string]: Array }, + rendererInterfaces: Map, + renderers: Map, + + emit: (event: string, data: any) => void, + getFiberRoots: (rendererID: RendererID) => Set, + inject: (renderer: ReactRenderer) => number | null, + on: (event: string, handler: Handler) => void, + off: (event: string, handler: Handler) => void, + reactDevtoolsAgent?: ?Object, + sub: (event: string, handler: Handler) => () => void, + + // Used by react-native-web and Flipper/Inspector + resolveRNStyle?: ResolveNativeStyle, + nativeStyleEditorValidAttributes?: $ReadOnlyArray, + + // React uses these methods. + checkDCE: (fn: Function) => void, + onCommitFiberUnmount: (rendererID: RendererID, fiber: Object) => void, + onCommitFiberRoot: ( + rendererID: RendererID, + fiber: Object, + commitPriority?: number + ) => void, +}; + +export type HooksNode = { + id: number, + isStateEditable: boolean, + name: string, + value: mixed, + subHooks: Array, +}; +export type HooksTree = Array; + +export type ReactEventResponder = { + $$typeof: Symbol | number, + displayName: string, + targetEventTypes: null | Array, + rootEventTypes: null | Array, + getInitialState: null | ((props: Object) => Object), + onEvent: + | null + | ((event: E, context: C, props: Object, state: Object) => void), + onRootEvent: + | null + | ((event: E, context: C, props: Object, state: Object) => void), + onMount: null | ((context: C, props: Object, state: Object) => void), + onUnmount: null | ((context: C, props: Object, state: Object) => void), + onOwnershipChange: + | null + | ((context: C, props: Object, state: Object) => void), +}; + +export type ReactEventResponderListener = {| + props: Object, + responder: ReactEventResponder, +|}; diff --git a/extension/src/backend/utils.js b/extension/src/backend/utils.js new file mode 100644 index 0000000000000..020d841d46ced --- /dev/null +++ b/extension/src/backend/utils.js @@ -0,0 +1,39 @@ +// @flow + +import { dehydrate } from '../hydration'; + +import type { DehydratedData } from 'src/devtools/views/Components/types'; + +export function cleanForBridge( + data: Object | null, + isPathWhitelisted: (path: Array) => boolean, + path?: Array = [] +): DehydratedData | null { + if (data !== null) { + const cleaned = []; + + return { + data: dehydrate(data, cleaned, path, isPathWhitelisted), + cleaned, + }; + } else { + return null; + } +} + +export function copyWithSet( + obj: Object | Array, + path: Array, + value: any, + index: number = 0 +): Object | Array { + console.log('[utils] copyWithSet()', obj, path, index, value); + if (index >= path.length) { + return value; + } + const key = path[index]; + const updated = Array.isArray(obj) ? obj.slice() : { ...obj }; + // $FlowFixMe number or string is fine here + updated[key] = copyWithSet(obj[key], path, value, index + 1); + return updated; +} diff --git a/extension/src/backend/views/Highlighter/Highlighter.js b/extension/src/backend/views/Highlighter/Highlighter.js new file mode 100644 index 0000000000000..1d838f8c124fa --- /dev/null +++ b/extension/src/backend/views/Highlighter/Highlighter.js @@ -0,0 +1,46 @@ +// @flow + +import Overlay from './Overlay'; + +const SHOW_DURATION = 2000; + +let timeoutID: TimeoutID | null = null; +let overlay: Overlay | null = null; + +export function hideOverlay() { + timeoutID = null; + + if (overlay !== null) { + overlay.remove(); + overlay = null; + } +} + +export function showOverlay( + elements: Array | null, + componentName: string | null, + hideAfterTimeout: boolean +) { + // TODO (npm-packages) Detect RN and support it somehow + if (window.document == null) { + return; + } + + if (timeoutID !== null) { + clearTimeout(timeoutID); + } + + if (elements == null) { + return; + } + + if (overlay === null) { + overlay = new Overlay(); + } + + overlay.inspect(elements, componentName); + + if (hideAfterTimeout) { + timeoutID = setTimeout(hideOverlay, SHOW_DURATION); + } +} diff --git a/extension/src/backend/views/Highlighter/Overlay.js b/extension/src/backend/views/Highlighter/Overlay.js new file mode 100644 index 0000000000000..5e59c2b93ff41 --- /dev/null +++ b/extension/src/backend/views/Highlighter/Overlay.js @@ -0,0 +1,454 @@ +// @flow + +import assign from 'object-assign'; + +type Rect = { + bottom: number, + height: number, + left: number, + right: number, + top: number, + width: number, +}; + +type Box = {| top: number, left: number, width: number, height: number |}; + +// Note that the Overlay components are not affected by the active Theme, +// because they highlight elements in the main Chrome window (outside of devtools). +// The colors below were chosen to roughly match those used by Chrome devtools. + +class OverlayRect { + node: HTMLElement; + border: HTMLElement; + padding: HTMLElement; + content: HTMLElement; + + constructor(doc: Document, container: HTMLElement) { + this.node = doc.createElement('div'); + this.border = doc.createElement('div'); + this.padding = doc.createElement('div'); + this.content = doc.createElement('div'); + + this.border.style.borderColor = overlayStyles.border; + this.padding.style.borderColor = overlayStyles.padding; + this.content.style.backgroundColor = overlayStyles.background; + + assign(this.node.style, { + borderColor: overlayStyles.margin, + pointerEvents: 'none', + position: 'fixed', + }); + + this.node.style.zIndex = '10000000'; + + this.node.appendChild(this.border); + this.border.appendChild(this.padding); + this.padding.appendChild(this.content); + container.appendChild(this.node); + } + + remove() { + if (this.node.parentNode) { + this.node.parentNode.removeChild(this.node); + } + } + + update(box: Rect, dims: any) { + boxWrap(dims, 'margin', this.node); + boxWrap(dims, 'border', this.border); + boxWrap(dims, 'padding', this.padding); + + assign(this.content.style, { + height: + box.height - + dims.borderTop - + dims.borderBottom - + dims.paddingTop - + dims.paddingBottom + + 'px', + width: + box.width - + dims.borderLeft - + dims.borderRight - + dims.paddingLeft - + dims.paddingRight + + 'px', + }); + + assign(this.node.style, { + top: box.top - dims.marginTop + 'px', + left: box.left - dims.marginLeft + 'px', + }); + } +} + +class OverlayTip { + tip: HTMLElement; + nameSpan: HTMLElement; + dimSpan: HTMLElement; + + constructor(doc: Document, container: HTMLElement) { + this.tip = doc.createElement('div'); + assign(this.tip.style, { + display: 'flex', + flexFlow: 'row nowrap', + backgroundColor: '#333740', + borderRadius: '2px', + fontFamily: + '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace', + fontWeight: 'bold', + padding: '3px 5px', + pointerEvents: 'none', + position: 'fixed', + fontSize: '12px', + whiteSpace: 'nowrap', + }); + + this.nameSpan = doc.createElement('span'); + this.tip.appendChild(this.nameSpan); + assign(this.nameSpan.style, { + color: '#ee78e6', + borderRight: '1px solid #aaaaaa', + paddingRight: '0.5rem', + marginRight: '0.5rem', + }); + this.dimSpan = doc.createElement('span'); + this.tip.appendChild(this.dimSpan); + assign(this.dimSpan.style, { + color: '#d7d7d7', + }); + + this.tip.style.zIndex = '10000000'; + container.appendChild(this.tip); + } + + remove() { + if (this.tip.parentNode) { + this.tip.parentNode.removeChild(this.tip); + } + } + + updateText(name: string, width: number, height: number) { + this.nameSpan.textContent = name; + this.dimSpan.textContent = + Math.round(width) + 'px × ' + Math.round(height) + 'px'; + } + + updatePosition(dims: Box, bounds: Box) { + const tipRect = this.tip.getBoundingClientRect(); + const tipPos = findTipPos(dims, bounds, { + width: tipRect.width, + height: tipRect.height, + }); + assign(this.tip.style, tipPos.style); + } +} + +export default class Overlay { + window: window; + tipBoundsWindow: window; + container: HTMLElement; + tip: OverlayTip; + rects: Array; + + constructor() { + // Find the root window, because overlays are positioned relative to it. + let currentWindow = window.__REACT_DEVTOOLS_TARGET_WINDOW__ || window; + this.window = currentWindow; + + // When opened in shells/dev, the tooltip should be bound by the app iframe, not by the topmost window. + let tipBoundsWindow = window.__REACT_DEVTOOLS_TARGET_WINDOW__ || window; + this.tipBoundsWindow = tipBoundsWindow; + + const doc = currentWindow.document; + this.container = doc.createElement('div'); + this.container.style.zIndex = '10000000'; + + this.tip = new OverlayTip(doc, this.container); + this.rects = []; + + doc.body.appendChild(this.container); + } + + remove() { + this.tip.remove(); + this.rects.forEach(rect => { + rect.remove(); + }); + this.rects.length = 0; + if (this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } + } + + inspect(nodes: Array, name?: ?string) { + // We can't get the size of text nodes or comment nodes. React as of v15 + // heavily uses comment nodes to delimit text. + const elements = nodes.filter(node => node.nodeType === Node.ELEMENT_NODE); + + while (this.rects.length > elements.length) { + const rect = this.rects.pop(); + rect.remove(); + } + if (elements.length === 0) { + return; + } + + while (this.rects.length < elements.length) { + this.rects.push(new OverlayRect(this.window.document, this.container)); + } + + const outerBox = { + top: Number.POSITIVE_INFINITY, + right: Number.NEGATIVE_INFINITY, + bottom: Number.NEGATIVE_INFINITY, + left: Number.POSITIVE_INFINITY, + }; + elements.forEach((element, index) => { + const box = getNestedBoundingClientRect(element, this.window); + const dims = getElementDimensions(element); + + outerBox.top = Math.min(outerBox.top, box.top - dims.marginTop); + outerBox.right = Math.max( + outerBox.right, + box.left + box.width + dims.marginRight + ); + outerBox.bottom = Math.max( + outerBox.bottom, + box.top + box.height + dims.marginBottom + ); + outerBox.left = Math.min(outerBox.left, box.left - dims.marginLeft); + + const rect = this.rects[index]; + rect.update(box, dims); + }); + + if (!name) { + name = elements[0].nodeName.toLowerCase(); + const ownerName = getOwnerDisplayName(elements[0]); + if (ownerName) { + name += ' (in ' + ownerName + ')'; + } + } + + this.tip.updateText( + name, + outerBox.right - outerBox.left, + outerBox.bottom - outerBox.top + ); + const tipBounds = getNestedBoundingClientRect( + this.tipBoundsWindow.document.documentElement, + this.window + ); + + this.tip.updatePosition( + { + top: outerBox.top, + left: outerBox.left, + height: outerBox.bottom - outerBox.top, + width: outerBox.right - outerBox.left, + }, + { + top: tipBounds.top + this.tipBoundsWindow.scrollY, + left: tipBounds.left + this.tipBoundsWindow.scrollX, + height: this.tipBoundsWindow.innerHeight, + width: this.tipBoundsWindow.innerWidth, + } + ); + } +} + +function getOwnerDisplayName(node) { + const fiber = getFiber(node); + if (fiber === null) { + return null; + } + const owner = fiber._debugOwner; + if (owner && owner.type) { + const ownerName = owner.type.displayName || owner.type.name; + return ownerName || null; + } + return null; +} + +let lastFoundInternalKey = null; +function getFiber(node) { + if ( + lastFoundInternalKey !== null && + node.hasOwnProperty(lastFoundInternalKey) + ) { + return (node: any)[lastFoundInternalKey]; + } + let internalKey = Object.keys(node).find( + key => key.indexOf('__reactInternalInstance') === 0 + ); + if (internalKey) { + lastFoundInternalKey = internalKey; + return (node: any)[lastFoundInternalKey]; + } + return null; +} + +function findTipPos(dims, bounds, tipSize) { + const tipHeight = Math.max(tipSize.height, 20); + const tipWidth = Math.max(tipSize.width, 60); + const margin = 5; + + let top; + if (dims.top + dims.height + tipHeight <= bounds.top + bounds.height) { + if (dims.top + dims.height < bounds.top + 0) { + top = bounds.top + margin; + } else { + top = dims.top + dims.height + margin; + } + } else if (dims.top - tipHeight <= bounds.top + bounds.height) { + if (dims.top - tipHeight - margin < bounds.top + margin) { + top = bounds.top + margin; + } else { + top = dims.top - tipHeight - margin; + } + } else { + top = bounds.top + bounds.height - tipHeight - margin; + } + + let left = dims.left + margin; + if (dims.left < bounds.left) { + left = bounds.left + margin; + } + if (dims.left + tipWidth > bounds.left + bounds.width) { + left = bounds.left + bounds.width - tipWidth - margin; + } + + top += 'px'; + left += 'px'; + return { + style: { top, left }, + }; +} + +export function getElementDimensions(domElement: Element) { + const calculatedStyle = window.getComputedStyle(domElement); + return { + borderLeft: parseInt(calculatedStyle.borderLeftWidth, 10), + borderRight: parseInt(calculatedStyle.borderRightWidth, 10), + borderTop: parseInt(calculatedStyle.borderTopWidth, 10), + borderBottom: parseInt(calculatedStyle.borderBottomWidth, 10), + marginLeft: parseInt(calculatedStyle.marginLeft, 10), + marginRight: parseInt(calculatedStyle.marginRight, 10), + marginTop: parseInt(calculatedStyle.marginTop, 10), + marginBottom: parseInt(calculatedStyle.marginBottom, 10), + paddingLeft: parseInt(calculatedStyle.paddingLeft, 10), + paddingRight: parseInt(calculatedStyle.paddingRight, 10), + paddingTop: parseInt(calculatedStyle.paddingTop, 10), + paddingBottom: parseInt(calculatedStyle.paddingBottom, 10), + }; +} + +// Get the window object for the document that a node belongs to, +// or return null if it cannot be found (node not attached to DOM, +// etc). +function getOwnerWindow(node: HTMLElement): typeof window | null { + if (!node.ownerDocument) { + return null; + } + return node.ownerDocument.defaultView; +} + +// Get the iframe containing a node, or return null if it cannot +// be found (node not within iframe, etc). +function getOwnerIframe(node: HTMLElement): HTMLElement | null { + const nodeWindow = getOwnerWindow(node); + if (nodeWindow) { + return nodeWindow.frameElement; + } + return null; +} + +// Get a bounding client rect for a node, with an +// offset added to compensate for its border. +function getBoundingClientRectWithBorderOffset(node: HTMLElement) { + const dimensions = getElementDimensions(node); + return mergeRectOffsets([ + node.getBoundingClientRect(), + { + top: dimensions.borderTop, + left: dimensions.borderLeft, + bottom: dimensions.borderBottom, + right: dimensions.borderRight, + // This width and height won't get used by mergeRectOffsets (since this + // is not the first rect in the array), but we set them so that this + // object typechecks as a ClientRect. + width: 0, + height: 0, + }, + ]); +} + +// Add together the top, left, bottom, and right properties of +// each ClientRect, but keep the width and height of the first one. +function mergeRectOffsets(rects: Array): Rect { + return rects.reduce((previousRect, rect) => { + if (previousRect == null) { + return rect; + } + + return { + top: previousRect.top + rect.top, + left: previousRect.left + rect.left, + width: previousRect.width, + height: previousRect.height, + bottom: previousRect.bottom + rect.bottom, + right: previousRect.right + rect.right, + }; + }); +} + +// Calculate a boundingClientRect for a node relative to boundaryWindow, +// taking into account any offsets caused by intermediate iframes. +function getNestedBoundingClientRect( + node: HTMLElement, + boundaryWindow: typeof window +): Rect { + const ownerIframe = getOwnerIframe(node); + if (ownerIframe && ownerIframe !== boundaryWindow) { + const rects = [node.getBoundingClientRect()]; + let currentIframe = ownerIframe; + let onlyOneMore = false; + while (currentIframe) { + const rect = getBoundingClientRectWithBorderOffset(currentIframe); + rects.push(rect); + currentIframe = getOwnerIframe(currentIframe); + + if (onlyOneMore) { + break; + } + // We don't want to calculate iframe offsets upwards beyond + // the iframe containing the boundaryWindow, but we + // need to calculate the offset relative to the boundaryWindow. + if (currentIframe && getOwnerWindow(currentIframe) === boundaryWindow) { + onlyOneMore = true; + } + } + + return mergeRectOffsets(rects); + } else { + return node.getBoundingClientRect(); + } +} + +function boxWrap(dims, what, node) { + assign(node.style, { + borderTopWidth: dims[what + 'Top'] + 'px', + borderLeftWidth: dims[what + 'Left'] + 'px', + borderRightWidth: dims[what + 'Right'] + 'px', + borderBottomWidth: dims[what + 'Bottom'] + 'px', + borderStyle: 'solid', + }); +} + +const overlayStyles = { + background: 'rgba(120, 170, 210, 0.7)', + padding: 'rgba(77, 200, 0, 0.3)', + margin: 'rgba(255, 155, 0, 0.3)', + border: 'rgba(255, 200, 50, 0.3)', +}; diff --git a/extension/src/backend/views/Highlighter/index.js b/extension/src/backend/views/Highlighter/index.js new file mode 100644 index 0000000000000..c50ef74c56501 --- /dev/null +++ b/extension/src/backend/views/Highlighter/index.js @@ -0,0 +1,187 @@ +// @flow + +import memoize from 'memoize-one'; +import throttle from 'lodash.throttle'; +import Agent from 'src/backend/agent'; +import { hideOverlay, showOverlay } from './Highlighter'; + +import type { BackendBridge } from 'src/bridge'; + +// This plug-in provides in-page highlighting of the selected element. +// It is used by the browser extension nad the standalone DevTools shell (when connected to a browser). +// It is not currently the mechanism used to highlight React Native views. +// That is done by the React Native Inspector component. + +let iframesListeningTo: Set = new Set(); + +export default function setupHighlighter( + bridge: BackendBridge, + agent: Agent +): void { + bridge.addListener( + 'clearNativeElementHighlight', + clearNativeElementHighlight + ); + bridge.addListener('highlightNativeElement', highlightNativeElement); + bridge.addListener('shutdown', stopInspectingNative); + bridge.addListener('startInspectingNative', startInspectingNative); + bridge.addListener('stopInspectingNative', stopInspectingNative); + + function startInspectingNative() { + registerListenersOnWindow(window); + } + + function registerListenersOnWindow(window) { + // This plug-in may run in non-DOM environments (e.g. React Native). + if (window && typeof window.addEventListener === 'function') { + window.addEventListener('click', onClick, true); + window.addEventListener('mousedown', onMouseEvent, true); + window.addEventListener('mouseover', onMouseEvent, true); + window.addEventListener('mouseup', onMouseEvent, true); + window.addEventListener('pointerdown', onPointerDown, true); + window.addEventListener('pointerover', onPointerOver, true); + window.addEventListener('pointerup', onPointerUp, true); + } + } + + function stopInspectingNative() { + hideOverlay(); + removeListenersOnWindow(window); + iframesListeningTo.forEach(function(frame) { + try { + removeListenersOnWindow(frame.contentWindow); + } catch (error) { + // This can error when the iframe is on a cross-origin. + } + }); + iframesListeningTo = new Set(); + } + + function removeListenersOnWindow(window) { + // This plug-in may run in non-DOM environments (e.g. React Native). + if (window && typeof window.removeEventListener === 'function') { + window.removeEventListener('click', onClick, true); + window.removeEventListener('mousedown', onMouseEvent, true); + window.removeEventListener('mouseover', onMouseEvent, true); + window.removeEventListener('mouseup', onMouseEvent, true); + window.removeEventListener('pointerdown', onPointerDown, true); + window.removeEventListener('pointerover', onPointerOver, true); + window.removeEventListener('pointerup', onPointerUp, true); + } + } + + function clearNativeElementHighlight() { + hideOverlay(); + } + + function highlightNativeElement({ + displayName, + hideAfterTimeout, + id, + openNativeElementsPanel, + rendererID, + scrollIntoView, + }: { + displayName: string | null, + hideAfterTimeout: boolean, + id: number, + openNativeElementsPanel: boolean, + rendererID: number, + scrollIntoView: boolean, + }) { + const renderer = agent.rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + } + + let nodes: ?Array = null; + if (renderer !== null) { + nodes = ((renderer.findNativeNodesForFiberID( + id + ): any): ?Array); + } + + if (nodes != null && nodes[0] != null) { + const node = nodes[0]; + if (scrollIntoView && typeof node.scrollIntoView === 'function') { + // If the node isn't visible show it before highlighting it. + // We may want to reconsider this; it might be a little disruptive. + node.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } + + showOverlay(nodes, displayName, hideAfterTimeout); + + if (openNativeElementsPanel) { + window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = node; + bridge.send('syncSelectionToNativeElementsPanel'); + } + } else { + hideOverlay(); + } + } + + function onClick(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + stopInspectingNative(); + + bridge.send('stopInspectingNative', true); + } + + function onMouseEvent(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + } + + function onPointerDown(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + selectFiberForNode(((event.target: any): HTMLElement)); + } + + function onPointerOver(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + const target = ((event.target: any): HTMLElement); + + if (target.tagName === 'IFRAME') { + const iframe: HTMLIFrameElement = (target: any); + try { + if (!iframesListeningTo.has(iframe)) { + const window = iframe.contentWindow; + registerListenersOnWindow(window); + iframesListeningTo.add(iframe); + } + } catch (error) { + // This can error when the iframe is on a cross-origin. + } + } + + // Don't pass the name explicitly. + // It will be inferred from DOM tag and Fiber owner. + showOverlay([target], null, false); + + selectFiberForNode(target); + } + + function onPointerUp(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + } + + const selectFiberForNode = throttle( + memoize((node: HTMLElement) => { + const id = agent.getIDForNode(node); + if (id !== null) { + bridge.send('selectFiber', id); + } + }), + 200, + // Don't change the selection in the very first 200ms + // because those are usually unintentional as you lift the cursor. + { leading: false } + ); +} diff --git a/extension/src/bridge.js b/extension/src/bridge.js new file mode 100644 index 0000000000000..cc110f8e5031a --- /dev/null +++ b/extension/src/bridge.js @@ -0,0 +1,237 @@ +// @flow + +import EventEmitter from 'events'; + +import type { ComponentFilter, Wall } from './types'; +import type { + InspectedElementPayload, + OwnersList, + ProfilingDataBackend, + RendererID, +} from 'src/backend/types'; +import type { StyleAndLayout as StyleAndLayoutPayload } from 'src/backend/NativeStyleEditor/types'; + +const BATCH_DURATION = 100; + +type ElementAndRendererID = {| id: number, rendererID: RendererID |}; + +type Message = {| + event: string, + payload: any, +|}; + +type HighlightElementInDOM = {| + ...ElementAndRendererID, + displayName: string | null, + hideAfterTimeout: boolean, + openNativeElementsPanel: boolean, + scrollIntoView: boolean, +|}; + +type OverrideValue = {| + ...ElementAndRendererID, + path: Array, + value: any, +|}; + +type OverrideHookState = {| + ...OverrideValue, + hookID: number, +|}; + +type OverrideSuspense = {| + ...ElementAndRendererID, + forceFallback: boolean, +|}; + +type InspectElementParams = {| + ...ElementAndRendererID, + path?: Array, +|}; + +type NativeStyleEditor_RenameAttributeParams = {| + ...ElementAndRendererID, + oldName: string, + newName: string, + value: string, +|}; + +type NativeStyleEditor_SetValueParams = {| + ...ElementAndRendererID, + name: string, + value: string, +|}; + +type BackendEvents = {| + inspectedElement: [InspectedElementPayload], + isBackendStorageAPISupported: [boolean], + operations: [Array], + ownersList: [OwnersList], + overrideComponentFilters: [Array], + profilingData: [ProfilingDataBackend], + profilingStatus: [boolean], + reloadAppForProfiling: [], + selectFiber: [number], + shutdown: [], + stopInspectingNative: [boolean], + syncSelectionFromNativeElementsPanel: [], + syncSelectionToNativeElementsPanel: [], + + // React Native style editor plug-in. + isNativeStyleEditorSupported: [ + {| isSupported: boolean, validAttributes: ?$ReadOnlyArray |}, + ], + NativeStyleEditor_styleAndLayout: [StyleAndLayoutPayload], +|}; + +type FrontendEvents = {| + clearNativeElementHighlight: [], + getOwnersList: [ElementAndRendererID], + getProfilingData: [{| rendererID: RendererID |}], + getProfilingStatus: [], + highlightNativeElement: [HighlightElementInDOM], + inspectElement: [InspectElementParams], + logElementToConsole: [ElementAndRendererID], + overrideContext: [OverrideValue], + overrideHookState: [OverrideHookState], + overrideProps: [OverrideValue], + overrideState: [OverrideValue], + overrideSuspense: [OverrideSuspense], + profilingData: [ProfilingDataBackend], + reloadAndProfile: [boolean], + selectFiber: [number], + shutdown: [], + startInspectingNative: [], + startProfiling: [boolean], + stopInspectingNative: [boolean], + stopProfiling: [], + updateAppendComponentStack: [boolean], + updateComponentFilters: [Array], + viewElementSource: [ElementAndRendererID], + + // React Native style editor plug-in. + NativeStyleEditor_measure: [ElementAndRendererID], + NativeStyleEditor_renameAttribute: [NativeStyleEditor_RenameAttributeParams], + NativeStyleEditor_setValue: [NativeStyleEditor_SetValueParams], +|}; + +class Bridge< + OutgoingEvents: Object, + IncomingEvents: Object +> extends EventEmitter<{| + ...IncomingEvents, + ...OutgoingEvents, +|}> { + _isShutdown: boolean = false; + _messageQueue: Array = []; + _timeoutID: TimeoutID | null = null; + _wall: Wall; + _wallUnlisten: Function | null = null; + + constructor(wall: Wall) { + super(); + + this._wall = wall; + + this._wallUnlisten = + wall.listen((message: Message) => { + (this: any).emit(message.event, message.payload); + }) || null; + } + + // Listening directly to the wall isn't advised. + // It can be used to listen for legacy (v3) messages (since they use a different format). + get wall(): Wall { + return this._wall; + } + + send>( + event: EventName, + ...payload: $ElementType + ) { + if (this._isShutdown) { + console.warn( + `Cannot send message "${event}" through a Bridge that has been shutdown.` + ); + return; + } + + // When we receive a message: + // - we add it to our queue of messages to be sent + // - if there hasn't been a message recently, we set a timer for 0 ms in + // the future, allowing all messages created in the same tick to be sent + // together + // - if there *has* been a message flushed in the last BATCH_DURATION ms + // (or we're waiting for our setTimeout-0 to fire), then _timeoutID will + // be set, and we'll simply add to the queue and wait for that + this._messageQueue.push(event, payload); + if (!this._timeoutID) { + this._timeoutID = setTimeout(this._flush, 0); + } + } + + shutdown() { + if (this._isShutdown) { + console.warn('Bridge was already shutdown.'); + return; + } + + // Queue the shutdown outgoing message for subscribers. + this.send('shutdown'); + + // Mark this bridge as destroyed, i.e. disable its public API. + this._isShutdown = true; + + // Disable the API inherited from EventEmitter that can add more listeners and send more messages. + // $FlowFixMe This property is not writable. + this.addListener = function() {}; + // $FlowFixMe This property is not writable. + this.emit = function() {}; + // NOTE: There's also EventEmitter API like `on` and `prependListener` that we didn't add to our Flow type of EventEmitter. + + // Unsubscribe this bridge incoming message listeners to be sure, and so they don't have to do that. + this.removeAllListeners(); + + // Stop accepting and emitting incoming messages from the wall. + const wallUnlisten = this._wallUnlisten; + if (wallUnlisten) { + wallUnlisten(); + } + + // Synchronously flush all queued outgoing messages. + // At this step the subscribers' code may run in this call stack. + do { + this._flush(); + } while (this._messageQueue.length); + + // Make sure once again that there is no dangling timer. + clearTimeout(this._timeoutID); + this._timeoutID = null; + } + + _flush = () => { + // This method is used after the bridge is marked as destroyed in shutdown sequence, + // so we do not bail out if the bridge marked as destroyed. + // It is a private method that the bridge ensures is only called at the right times. + + clearTimeout(this._timeoutID); + this._timeoutID = null; + + if (this._messageQueue.length) { + for (let i = 0; i < this._messageQueue.length; i += 2) { + this._wall.send(this._messageQueue[i], ...this._messageQueue[i + 1]); + } + this._messageQueue.length = 0; + + // Check again for queued messages in BATCH_DURATION ms. This will keep + // flushing in a loop as long as messages continue to be added. Once no + // more are, the timer expires. + this._timeoutID = setTimeout(this._flush, BATCH_DURATION); + } + }; +} + +export type BackendBridge = Bridge; +export type FrontendBridge = Bridge; + +export default Bridge; diff --git a/extension/src/constants.js b/extension/src/constants.js new file mode 100644 index 0000000000000..d27de3ed5bc40 --- /dev/null +++ b/extension/src/constants.js @@ -0,0 +1,57 @@ +// @flow + +// Flip this flag to true to enable verbose console debug logging. +export const __DEBUG__ = false; + +export const TREE_OPERATION_ADD = 1; +export const TREE_OPERATION_REMOVE = 2; +export const TREE_OPERATION_REORDER_CHILDREN = 3; +export const TREE_OPERATION_UPDATE_TREE_BASE_DURATION = 4; + +export const LOCAL_STORAGE_FILTER_PREFERENCES_KEY = + 'React::DevTools::componentFilters'; + +export const SESSION_STORAGE_LAST_SELECTION_KEY = + 'React::DevTools::lastSelection'; + +export const SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY = + 'React::DevTools::recordChangeDescriptions'; + +export const SESSION_STORAGE_RELOAD_AND_PROFILE_KEY = + 'React::DevTools::reloadAndProfile'; + +export const LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY = + 'React::DevTools::appendComponentStack'; + +export const PROFILER_EXPORT_VERSION = 4; + +export const CHANGE_LOG_URL = + 'https://github.com/bvaughn/react-devtools-experimental/blob/master/CHANGELOG.md'; + +// HACK +// +// Extracting during build time avoids a temporarily invalid state for the inline target. +// Sometimes the inline target is rendered before root styles are applied, +// which would result in e.g. NaN itemSize being passed to react-window list. +// +// We can't use the Webpack loader syntax in the context of Jest though, +// so tests need some reasonably meaningful fallback value. +let COMFORTABLE_LINE_HEIGHT = 15; +let COMPACT_LINE_HEIGHT = 10; + +if (!__TEST__) { + // $FlowFixMe + const rawStyleString = require('!!raw-loader!src/devtools/views/root.css') // eslint-disable-line import/no-webpack-loader-syntax + .default; + + const extractVar = varName => { + const regExp = new RegExp(`${varName}: ([0-9]+)`); + const match = rawStyleString.match(regExp); + return parseInt(match[1], 10); + }; + + COMFORTABLE_LINE_HEIGHT = extractVar('comfortable-line-height-data'); + COMPACT_LINE_HEIGHT = extractVar('compact-line-height-data'); +} + +export { COMFORTABLE_LINE_HEIGHT, COMPACT_LINE_HEIGHT }; diff --git a/extension/src/devtools/ProfilerStore.js b/extension/src/devtools/ProfilerStore.js new file mode 100644 index 0000000000000..103947ac8c802 --- /dev/null +++ b/extension/src/devtools/ProfilerStore.js @@ -0,0 +1,330 @@ +// @flow + +import EventEmitter from 'events'; +import { prepareProfilingDataFrontendFromBackendAndStore } from './views/Profiler/utils'; +import ProfilingCache from './ProfilingCache'; +import Store from './store'; + +import type { FrontendBridge } from 'src/bridge'; +import type { ProfilingDataBackend } from 'src/backend/types'; +import type { + CommitDataFrontend, + ProfilingDataForRootFrontend, + ProfilingDataFrontend, + SnapshotNode, +} from './views/Profiler/types'; + +export default class ProfilerStore extends EventEmitter<{| + isProcessingData: [], + isProfiling: [], + profilingData: [], +|}> { + _bridge: FrontendBridge; + + // Suspense cache for lazily calculating derived profiling data. + _cache: ProfilingCache; + + // Temporary store of profiling data from the backend renderer(s). + // This data will be converted to the ProfilingDataFrontend format after being collected from all renderers. + _dataBackends: Array = []; + + // Data from the most recently completed profiling session, + // or data that has been imported from a previously exported session. + // This object contains all necessary data to drive the Profiler UI interface, + // even though some of it is lazily parsed/derived via the ProfilingCache. + _dataFrontend: ProfilingDataFrontend | null = null; + + // Snapshot of all attached renderer IDs. + // Once profiling is finished, this snapshot will be used to query renderers for profiling data. + // + // This map is initialized when profiling starts and updated when a new root is added while profiling; + // Upon completion, it is converted into the exportable ProfilingDataFrontend format. + _initialRendererIDs: Set = new Set(); + + // Snapshot of the state of the main Store (including all roots) when profiling started. + // Once profiling is finished, this snapshot can be used along with "operations" messages emitted during profiling, + // to reconstruct the state of each root for each commit. + // It's okay to use a single root to store this information because node IDs are unique across all roots. + // + // This map is initialized when profiling starts and updated when a new root is added while profiling; + // Upon completion, it is converted into the exportable ProfilingDataFrontend format. + _initialSnapshotsByRootID: Map> = new Map(); + + // Map of root (id) to a list of tree mutation that occur during profiling. + // Once profiling is finished, these mutations can be used, along with the initial tree snapshots, + // to reconstruct the state of each root for each commit. + // + // This map is only updated while profiling is in progress; + // Upon completion, it is converted into the exportable ProfilingDataFrontend format. + _inProgressOperationsByRootID: Map>> = new Map(); + + // The backend is currently profiling. + // When profiling is in progress, operations are stored so that we can later reconstruct past commit trees. + _isProfiling: boolean = false; + + // After profiling, data is requested from each attached renderer using this queue. + // So long as this queue is not empty, the store is retrieving and processing profiling data from the backend. + _rendererQueue: Set = new Set(); + + _store: Store; + + constructor( + bridge: FrontendBridge, + store: Store, + defaultIsProfiling: boolean + ) { + super(); + + this._bridge = bridge; + this._isProfiling = defaultIsProfiling; + this._store = store; + + bridge.addListener('operations', this.onBridgeOperations); + bridge.addListener('profilingData', this.onBridgeProfilingData); + bridge.addListener('profilingStatus', this.onProfilingStatus); + bridge.addListener('shutdown', this.onBridgeShutdown); + + // It's possible that profiling has already started (e.g. "reload and start profiling") + // so the frontend needs to ask the backend for its status after mounting. + bridge.send('getProfilingStatus'); + + this._cache = new ProfilingCache(this); + } + + getCommitData(rootID: number, commitIndex: number): CommitDataFrontend { + if (this._dataFrontend !== null) { + const dataForRoot = this._dataFrontend.dataForRoots.get(rootID); + if (dataForRoot != null) { + const commitDatum = dataForRoot.commitData[commitIndex]; + if (commitDatum != null) { + return commitDatum; + } + } + } + + throw Error( + `Could not find commit data for root "${rootID}" and commit ${commitIndex}` + ); + } + + getDataForRoot(rootID: number): ProfilingDataForRootFrontend { + if (this._dataFrontend !== null) { + const dataForRoot = this._dataFrontend.dataForRoots.get(rootID); + if (dataForRoot != null) { + return dataForRoot; + } + } + + throw Error(`Could not find commit data for root "${rootID}"`); + } + + // Profiling data has been recorded for at least one root. + get didRecordCommits(): boolean { + return ( + this._dataFrontend !== null && this._dataFrontend.dataForRoots.size > 0 + ); + } + + get isProcessingData(): boolean { + return this._rendererQueue.size > 0 || this._dataBackends.length > 0; + } + + get isProfiling(): boolean { + return this._isProfiling; + } + + get profilingCache(): ProfilingCache { + return this._cache; + } + + get profilingData(): ProfilingDataFrontend | null { + return this._dataFrontend; + } + set profilingData(value: ProfilingDataFrontend | null): void { + if (this._isProfiling) { + console.warn( + 'Profiling data cannot be updated while profiling is in progress.' + ); + return; + } + + this._dataBackends.splice(0); + this._dataFrontend = value; + this._initialRendererIDs.clear(); + this._initialSnapshotsByRootID.clear(); + this._inProgressOperationsByRootID.clear(); + this._cache.invalidate(); + + this.emit('profilingData'); + } + + clear(): void { + this._dataBackends.splice(0); + this._dataFrontend = null; + this._initialRendererIDs.clear(); + this._initialSnapshotsByRootID.clear(); + this._inProgressOperationsByRootID.clear(); + this._rendererQueue.clear(); + + // Invalidate suspense cache if profiling data is being (re-)recorded. + // Note that we clear now because any existing data is "stale". + this._cache.invalidate(); + + this.emit('profilingData'); + } + + startProfiling(): void { + this._bridge.send('startProfiling', this._store.recordChangeDescriptions); + + // Don't actually update the local profiling boolean yet! + // Wait for onProfilingStatus() to confirm the status has changed. + // This ensures the frontend and backend are in sync wrt which commits were profiled. + // We do this to avoid mismatches on e.g. CommitTreeBuilder that would cause errors. + } + + stopProfiling(): void { + this._bridge.send('stopProfiling'); + + // Don't actually update the local profiling boolean yet! + // Wait for onProfilingStatus() to confirm the status has changed. + // This ensures the frontend and backend are in sync wrt which commits were profiled. + // We do this to avoid mismatches on e.g. CommitTreeBuilder that would cause errors. + } + + _takeProfilingSnapshotRecursive = ( + elementID: number, + profilingSnapshots: Map + ) => { + const element = this._store.getElementByID(elementID); + if (element !== null) { + const snapshotNode: SnapshotNode = { + id: elementID, + children: element.children.slice(0), + displayName: element.displayName, + key: element.key, + type: element.type, + }; + profilingSnapshots.set(elementID, snapshotNode); + + element.children.forEach(childID => + this._takeProfilingSnapshotRecursive(childID, profilingSnapshots) + ); + } + }; + + onBridgeOperations = (operations: Array) => { + // The first two values are always rendererID and rootID + const rendererID = operations[0]; + const rootID = operations[1]; + + if (this._isProfiling) { + let profilingOperations = this._inProgressOperationsByRootID.get(rootID); + if (profilingOperations == null) { + profilingOperations = [operations]; + this._inProgressOperationsByRootID.set(rootID, profilingOperations); + } else { + profilingOperations.push(operations); + } + + if (!this._initialRendererIDs.has(rendererID)) { + this._initialRendererIDs.add(rendererID); + } + + if (!this._initialSnapshotsByRootID.has(rootID)) { + this._initialSnapshotsByRootID.set(rootID, new Map()); + } + } + }; + + onBridgeProfilingData = (dataBackend: ProfilingDataBackend) => { + if (this._isProfiling) { + // This should never happen, but if it does- ignore previous profiling data. + return; + } + + const { rendererID } = dataBackend; + + if (!this._rendererQueue.has(rendererID)) { + throw Error( + `Unexpected profiling data update from renderer "${rendererID}"` + ); + } + + this._dataBackends.push(dataBackend); + this._rendererQueue.delete(rendererID); + + if (this._rendererQueue.size === 0) { + this._dataFrontend = prepareProfilingDataFrontendFromBackendAndStore( + this._dataBackends, + this._inProgressOperationsByRootID, + this._initialSnapshotsByRootID + ); + + this._dataBackends.splice(0); + + this.emit('isProcessingData'); + } + }; + + onBridgeShutdown = () => { + this._bridge.removeListener('operations', this.onBridgeOperations); + this._bridge.removeListener('profilingData', this.onBridgeProfilingData); + this._bridge.removeListener('profilingStatus', this.onProfilingStatus); + this._bridge.removeListener('shutdown', this.onBridgeShutdown); + }; + + onProfilingStatus = (isProfiling: boolean) => { + if (isProfiling) { + this._dataBackends.splice(0); + this._dataFrontend = null; + this._initialRendererIDs.clear(); + this._initialSnapshotsByRootID.clear(); + this._inProgressOperationsByRootID.clear(); + this._rendererQueue.clear(); + + // Record all renderer IDs initially too (in case of unmount) + for (let rendererID of this._store.rootIDToRendererID.values()) { + if (!this._initialRendererIDs.has(rendererID)) { + this._initialRendererIDs.add(rendererID); + } + } + + // Record snapshot of tree at the time profiling is started. + // This info is required to handle cases of e.g. nodes being removed during profiling. + this._store.roots.forEach(rootID => { + const profilingSnapshots = new Map(); + this._initialSnapshotsByRootID.set(rootID, profilingSnapshots); + this._takeProfilingSnapshotRecursive(rootID, profilingSnapshots); + }); + } + + if (this._isProfiling !== isProfiling) { + this._isProfiling = isProfiling; + + // Invalidate suspense cache if profiling data is being (re-)recorded. + // Note that we clear again, in case any views read from the cache while profiling. + // (That would have resolved a now-stale value without any profiling data.) + this._cache.invalidate(); + + this.emit('isProfiling'); + + // If we've just finished a profiling session, we need to fetch data stored in each renderer interface + // and re-assemble it on the front-end into a format (ProfilingDataFrontend) that can power the Profiler UI. + // During this time, DevTools UI should probably not be interactive. + if (!isProfiling) { + this._dataBackends.splice(0); + this._rendererQueue.clear(); + + this._initialRendererIDs.forEach(rendererID => { + if (!this._rendererQueue.has(rendererID)) { + this._rendererQueue.add(rendererID); + + this._bridge.send('getProfilingData', { rendererID }); + } + }); + + this.emit('isProcessingData'); + } + } + }; +} diff --git a/extension/src/devtools/ProfilingCache.js b/extension/src/devtools/ProfilingCache.js new file mode 100644 index 0000000000000..d8f1819ddb632 --- /dev/null +++ b/extension/src/devtools/ProfilingCache.js @@ -0,0 +1,122 @@ +// @flow + +import ProfilerStore from './ProfilerStore'; +import { + getCommitTree, + invalidateCommitTrees, +} from 'src/devtools/views/Profiler/CommitTreeBuilder'; +import { + getChartData as getFlamegraphChartData, + invalidateChartData as invalidateFlamegraphChartData, +} from 'src/devtools/views/Profiler/FlamegraphChartBuilder'; +import { + getChartData as getInteractionsChartData, + invalidateChartData as invalidateInteractionsChartData, +} from 'src/devtools/views/Profiler/InteractionsChartBuilder'; +import { + getChartData as getRankedChartData, + invalidateChartData as invalidateRankedChartData, +} from 'src/devtools/views/Profiler/RankedChartBuilder'; + +import type { CommitTree } from 'src/devtools/views/Profiler/types'; +import type { ChartData as FlamegraphChartData } from 'src/devtools/views/Profiler/FlamegraphChartBuilder'; +import type { ChartData as InteractionsChartData } from 'src/devtools/views/Profiler/InteractionsChartBuilder'; +import type { ChartData as RankedChartData } from 'src/devtools/views/Profiler/RankedChartBuilder'; + +export default class ProfilingCache { + _fiberCommits: Map> = new Map(); + _profilerStore: ProfilerStore; + + constructor(profilerStore: ProfilerStore) { + this._profilerStore = profilerStore; + } + + getCommitTree = ({ + commitIndex, + rootID, + }: {| + commitIndex: number, + rootID: number, + |}) => + getCommitTree({ + commitIndex, + profilerStore: this._profilerStore, + rootID, + }); + + getFiberCommits = ({ + fiberID, + rootID, + }: {| + fiberID: number, + rootID: number, + |}): Array => { + const cachedFiberCommits = this._fiberCommits.get(fiberID); + if (cachedFiberCommits != null) { + return cachedFiberCommits; + } + + const fiberCommits = []; + const dataForRoot = this._profilerStore.getDataForRoot(rootID); + dataForRoot.commitData.forEach((commitDatum, commitIndex) => { + if (commitDatum.fiberActualDurations.has(fiberID)) { + fiberCommits.push(commitIndex); + } + }); + + this._fiberCommits.set(fiberID, fiberCommits); + + return fiberCommits; + }; + + getFlamegraphChartData = ({ + commitIndex, + commitTree, + rootID, + }: {| + commitIndex: number, + commitTree: CommitTree, + rootID: number, + |}): FlamegraphChartData => + getFlamegraphChartData({ + commitIndex, + commitTree, + profilerStore: this._profilerStore, + rootID, + }); + + getInteractionsChartData = ({ + rootID, + }: {| + rootID: number, + |}): InteractionsChartData => + getInteractionsChartData({ + profilerStore: this._profilerStore, + rootID, + }); + + getRankedChartData = ({ + commitIndex, + commitTree, + rootID, + }: {| + commitIndex: number, + commitTree: CommitTree, + rootID: number, + |}): RankedChartData => + getRankedChartData({ + commitIndex, + commitTree, + profilerStore: this._profilerStore, + rootID, + }); + + invalidate() { + this._fiberCommits.clear(); + + invalidateCommitTrees(); + invalidateFlamegraphChartData(); + invalidateInteractionsChartData(); + invalidateRankedChartData(); + } +} diff --git a/extension/src/devtools/cache.js b/extension/src/devtools/cache.js new file mode 100644 index 0000000000000..1e1279803093b --- /dev/null +++ b/extension/src/devtools/cache.js @@ -0,0 +1,198 @@ +// @flow + +import React, { createContext } from 'react'; + +// Cache implementation was forked from the React repo: +// https://github.com/facebook/react/blob/master/packages/react-cache/src/ReactCache.js +// +// This cache is simpler than react-cache in that: +// 1. Individual items don't need to be invalidated. +// Profiling data is invalidated as a whole. +// 2. We didn't need the added overhead of an LRU cache. +// The size of this cache is bounded by how many renders were profiled, +// and it will be fully reset between profiling sessions. + +export type Thenable = { + then(resolve: (T) => mixed, reject: (mixed) => mixed): mixed, +}; + +type Suspender = { + then(resolve: () => mixed, reject: () => mixed): mixed, +}; + +type PendingResult = {| + status: 0, + value: Suspender, +|}; + +type ResolvedResult = {| + status: 1, + value: Value, +|}; + +type RejectedResult = {| + status: 2, + value: mixed, +|}; + +type Result = PendingResult | ResolvedResult | RejectedResult; + +export type Resource = { + clear(): void, + invalidate(Key): void, + read(Input): Value, + preload(Input): void, + write(Key, Value): void, +}; + +const Pending = 0; +const Resolved = 1; +const Rejected = 2; + +const ReactCurrentDispatcher = (React: any) + .__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher; + +function readContext(Context, observedBits) { + const dispatcher = ReactCurrentDispatcher.current; + if (dispatcher === null) { + throw new Error( + 'react-cache: read and preload may only be called from within a ' + + "component's render. They are not supported in event handlers or " + + 'lifecycle methods.' + ); + } + return dispatcher.readContext(Context, observedBits); +} + +const CacheContext = createContext(null); + +type Config = { + useWeakMap?: boolean, +}; + +const entries: Map< + Resource, + Map | WeakMap +> = new Map(); +const resourceConfigs: Map, Config> = new Map(); + +function getEntriesForResource( + resource: any +): Map | WeakMap { + let entriesForResource = ((entries.get(resource): any): Map); + if (entriesForResource === undefined) { + const config = resourceConfigs.get(resource); + entriesForResource = + config !== undefined && config.useWeakMap ? new WeakMap() : new Map(); + entries.set(resource, entriesForResource); + } + return entriesForResource; +} + +function accessResult( + resource: any, + fetch: Input => Thenable, + input: Input, + key: Key +): Result { + const entriesForResource = getEntriesForResource(resource); + const entry = entriesForResource.get(key); + if (entry === undefined) { + const thenable = fetch(input); + thenable.then( + value => { + if (newResult.status === Pending) { + const resolvedResult: ResolvedResult = (newResult: any); + resolvedResult.status = Resolved; + resolvedResult.value = value; + } + }, + error => { + if (newResult.status === Pending) { + const rejectedResult: RejectedResult = (newResult: any); + rejectedResult.status = Rejected; + rejectedResult.value = error; + } + } + ); + const newResult: PendingResult = { + status: Pending, + value: thenable, + }; + entriesForResource.set(key, newResult); + return newResult; + } else { + return entry; + } +} + +export function createResource( + fetch: Input => Thenable, + hashInput: Input => Key, + config?: Config = {} +): Resource { + const resource = { + clear(): void { + entries.delete(resource); + }, + + invalidate(key: Key): void { + const entriesForResource = getEntriesForResource(resource); + entriesForResource.delete(key); + }, + + read(input: Input): Value { + // Prevent access outside of render. + // eslint-disable-next-line react-hooks/rules-of-hooks + readContext(CacheContext); + + const key = hashInput(input); + const result: Result = accessResult(resource, fetch, input, key); + switch (result.status) { + case Pending: { + const suspender = result.value; + throw suspender; + } + case Resolved: { + const value = result.value; + return value; + } + case Rejected: { + const error = result.value; + throw error; + } + default: + // Should be unreachable + return (undefined: any); + } + }, + + preload(input: Input): void { + // Prevent access outside of render. + // eslint-disable-next-line react-hooks/rules-of-hooks + readContext(CacheContext); + + const key = hashInput(input); + accessResult(resource, fetch, input, key); + }, + + write(key: Key, value: Value): void { + const entriesForResource = getEntriesForResource(resource); + + const resolvedResult: ResolvedResult = { + status: Resolved, + value, + }; + + entriesForResource.set(key, resolvedResult); + }, + }; + + resourceConfigs.set(resource, config); + + return resource; +} + +export function invalidateResources(): void { + entries.clear(); +} diff --git a/extension/src/devtools/index.js b/extension/src/devtools/index.js new file mode 100644 index 0000000000000..33e628e96542a --- /dev/null +++ b/extension/src/devtools/index.js @@ -0,0 +1,14 @@ +// @flow + +import type { FrontendBridge } from 'src/bridge'; + +type Shell = {| + connect: (callback: Function) => void, + onReload: (reloadFn: Function) => void, +|}; + +export function initDevTools(shell: Shell) { + shell.connect((bridge: FrontendBridge) => { + // TODO ... + }); +} diff --git a/extension/src/devtools/store.js b/extension/src/devtools/store.js new file mode 100644 index 0000000000000..e032463957e08 --- /dev/null +++ b/extension/src/devtools/store.js @@ -0,0 +1,1005 @@ +// @flow + +import EventEmitter from 'events'; +import { inspect } from 'util'; +import { + TREE_OPERATION_ADD, + TREE_OPERATION_REMOVE, + TREE_OPERATION_REORDER_CHILDREN, + TREE_OPERATION_UPDATE_TREE_BASE_DURATION, +} from '../constants'; +import { ElementTypeRoot } from '../types'; +import { + getSavedComponentFilters, + saveComponentFilters, + separateDisplayNameAndHOCs, + shallowDiffers, + utfDecodeString, +} from '../utils'; +import { localStorageGetItem, localStorageSetItem } from '../storage'; +import { __DEBUG__ } from '../constants'; +import { printStore } from 'src/__tests__/storeSerializer'; +import ProfilerStore from './ProfilerStore'; + +import type { Element } from './views/Components/types'; +import type { ComponentFilter, ElementType } from '../types'; +import type { FrontendBridge } from 'src/bridge'; + +const debug = (methodName, ...args) => { + if (__DEBUG__) { + console.log( + `%cStore %c${methodName}`, + 'color: green; font-weight: bold;', + 'font-weight: bold;', + ...args + ); + } +}; + +const LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY = + 'React::DevTools::collapseNodesByDefault'; +const LOCAL_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY = + 'React::DevTools::recordChangeDescriptions'; + +type Config = {| + isProfiling?: boolean, + supportsNativeInspection?: boolean, + supportsReloadAndProfile?: boolean, + supportsProfiling?: boolean, +|}; + +export type Capabilities = {| + hasOwnerMetadata: boolean, + supportsProfiling: boolean, +|}; + +/** + * The store is the single source of truth for updates from the backend. + * ContextProviders can subscribe to the Store for specific things they want to provide. + */ +export default class Store extends EventEmitter<{| + collapseNodesByDefault: [], + componentFilters: [], + mutated: [[Array, Map]], + recordChangeDescriptions: [], + roots: [], + supportsNativeStyleEditor: [], + supportsProfiling: [], + supportsReloadAndProfile: [], +|}> { + _bridge: FrontendBridge; + + // Should new nodes be collapsed by default when added to the tree? + _collapseNodesByDefault: boolean = true; + + _componentFilters: Array; + + // At least one of the injected renderers contains (DEV only) owner metadata. + _hasOwnerMetadata: boolean = false; + + // 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. + _idToElement: Map = new Map(); + + // Should the React Native style editor panel be shown? + _isNativeStyleEditorSupported: boolean = false; + + // Can the backend use the Storage API (e.g. localStorage)? + // If not, features like reload-and-profile will not work correctly and must be disabled. + _isBackendStorageAPISupported: boolean = false; + + _nativeStyleEditorValidAttributes: $ReadOnlyArray | null = null; + + // Map of element (id) to the set of elements (ids) it owns. + // This map enables getOwnersListForElement() to avoid traversing the entire tree. + _ownersMap: Map> = new Map(); + + _profilerStore: ProfilerStore; + + _recordChangeDescriptions: boolean = false; + + // Incremented each time the store is mutated. + // This enables a passive effect to detect a mutation between render and commit phase. + _revision: number = 0; + + // This Array must be treated as immutable! + // Passive effects will check it for changes between render and mount. + _roots: $ReadOnlyArray = []; + + _rootIDToCapabilities: Map = new Map(); + + // Renderer ID is needed to support inspection fiber props, state, and hooks. + _rootIDToRendererID: Map = new Map(); + + // These options may be initially set by a confiugraiton option when constructing the Store. + // In the case of "supportsProfiling", the option may be updated based on the injected renderers. + _supportsNativeInspection: boolean = true; + _supportsProfiling: boolean = false; + _supportsReloadAndProfile: boolean = false; + + // Total number of visible elements (within all roots). + // Used for windowing purposes. + _weightAcrossRoots: number = 0; + + constructor(bridge: FrontendBridge, config?: Config) { + super(); + + if (__DEBUG__) { + debug('constructor', 'subscribing to Bridge'); + } + + this._collapseNodesByDefault = + localStorageGetItem(LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY) === + 'true'; + + this._recordChangeDescriptions = + localStorageGetItem(LOCAL_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY) === + 'true'; + + this._componentFilters = getSavedComponentFilters(); + + let isProfiling = false; + if (config != null) { + isProfiling = config.isProfiling === true; + + const { + supportsNativeInspection, + supportsProfiling, + supportsReloadAndProfile, + } = config; + this._supportsNativeInspection = supportsNativeInspection !== false; + if (supportsProfiling) { + this._supportsProfiling = true; + } + if (supportsReloadAndProfile) { + this._supportsReloadAndProfile = true; + } + } + + this._bridge = bridge; + bridge.addListener('operations', this.onBridgeOperations); + bridge.addListener( + 'overrideComponentFilters', + this.onBridgeOverrideComponentFilters + ); + bridge.addListener('shutdown', this.onBridgeShutdown); + bridge.addListener( + 'isBackendStorageAPISupported', + this.onBridgeStorageSupported + ); + bridge.addListener( + 'isNativeStyleEditorSupported', + this.onBridgeNativeStyleEditorSupported + ); + + this._profilerStore = new ProfilerStore(bridge, this, isProfiling); + } + + // This is only used in tests to avoid memory leaks. + assertExpectedRootMapSizes() { + if (this.roots.length === 0) { + // The only safe time to assert these maps are empty is when the store is empty. + this.assertMapSizeMatchesRootCount(this._idToElement, '_idToElement'); + this.assertMapSizeMatchesRootCount(this._ownersMap, '_ownersMap'); + } + + // These maps should always be the same size as the number of roots + this.assertMapSizeMatchesRootCount( + this._rootIDToCapabilities, + '_rootIDToCapabilities' + ); + this.assertMapSizeMatchesRootCount( + this._rootIDToRendererID, + '_rootIDToRendererID' + ); + } + + // This is only used in tests to avoid memory leaks. + assertMapSizeMatchesRootCount(map: Map, mapName: string) { + const expectedSize = this.roots.length; + if (map.size !== expectedSize) { + throw new Error( + `Expected ${mapName} to contain ${expectedSize} items, but it contains ${ + map.size + } items\n\n${inspect(map, { + depth: 20, + })}` + ); + } + } + + get collapseNodesByDefault(): boolean { + return this._collapseNodesByDefault; + } + set collapseNodesByDefault(value: boolean): void { + this._collapseNodesByDefault = value; + + localStorageSetItem( + LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY, + value ? 'true' : 'false' + ); + + this.emit('collapseNodesByDefault'); + } + + get componentFilters(): Array { + return this._componentFilters; + } + set componentFilters(value: Array): void { + if (this._profilerStore.isProfiling) { + // Re-mounting a tree while profiling is in progress might break a lot of assumptions. + // If necessary, we could support this- but it doesn't seem like a necessary use case. + throw Error('Cannot modify filter preferences while profiling'); + } + + // Filter updates are expensive to apply (since they impact the entire tree). + // Let's determine if they've changed and avoid doing this work if they haven't. + const prevEnabledComponentFilters = this._componentFilters.filter( + filter => filter.isEnabled + ); + const nextEnabledComponentFilters = value.filter( + filter => filter.isEnabled + ); + let haveEnabledFiltersChanged = + prevEnabledComponentFilters.length !== nextEnabledComponentFilters.length; + if (!haveEnabledFiltersChanged) { + for (let i = 0; i < nextEnabledComponentFilters.length; i++) { + const prevFilter = prevEnabledComponentFilters[i]; + const nextFilter = nextEnabledComponentFilters[i]; + if (shallowDiffers(prevFilter, nextFilter)) { + haveEnabledFiltersChanged = true; + break; + } + } + } + + this._componentFilters = value; + + // Update persisted filter preferences stored in localStorage. + saveComponentFilters(value); + + // Notify the renderer that filter prefernces have changed. + // This is an expensive opreation; it unmounts and remounts the entire tree, + // so only do it if the set of enabled component filters has changed. + if (haveEnabledFiltersChanged) { + this._bridge.send('updateComponentFilters', value); + } + + this.emit('componentFilters'); + } + + get hasOwnerMetadata(): boolean { + return this._hasOwnerMetadata; + } + + get nativeStyleEditorValidAttributes(): $ReadOnlyArray | null { + return this._nativeStyleEditorValidAttributes; + } + + get numElements(): number { + return this._weightAcrossRoots; + } + + get profilerStore(): ProfilerStore { + return this._profilerStore; + } + + get recordChangeDescriptions(): boolean { + return this._recordChangeDescriptions; + } + set recordChangeDescriptions(value: boolean): void { + this._recordChangeDescriptions = value; + + localStorageSetItem( + LOCAL_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY, + value ? 'true' : 'false' + ); + + this.emit('recordChangeDescriptions'); + } + + get revision(): number { + return this._revision; + } + + get rootIDToRendererID(): Map { + return this._rootIDToRendererID; + } + + get roots(): $ReadOnlyArray { + return this._roots; + } + + get supportsNativeInspection(): boolean { + return this._supportsNativeInspection; + } + + get supportsNativeStyleEditor(): boolean { + return this._isNativeStyleEditorSupported; + } + + get supportsProfiling(): boolean { + return this._supportsProfiling; + } + + get supportsReloadAndProfile(): boolean { + // Does the DevTools shell support reloading and eagerly injecting the renderer interface? + // And if so, can the backend use the localStorage API? + // Both of these are required for the reload-and-profile feature to work. + return this._supportsReloadAndProfile && this._isBackendStorageAPISupported; + } + + containsElement(id: number): boolean { + return this._idToElement.get(id) != null; + } + + getElementAtIndex(index: number): Element | null { + if (index < 0 || index >= this.numElements) { + console.warn( + `Invalid index ${index} specified; store contains ${ + this.numElements + } items.` + ); + + return null; + } + + // Find wich root this element is in... + let rootID; + let root; + let rootWeight = 0; + for (let i = 0; i < this._roots.length; i++) { + rootID = this._roots[i]; + root = ((this._idToElement.get(rootID): any): Element); + if (root.children.length === 0) { + continue; + } else if (rootWeight + root.weight > index) { + break; + } else { + rootWeight += root.weight; + } + } + + // Find the element in the tree using the weight of each node... + // Skip over the root itself, because roots aren't visible in the Elements tree. + let currentElement = ((root: any): Element); + let currentWeight = rootWeight - 1; + while (index !== currentWeight) { + const numChildren = currentElement.children.length; + for (let i = 0; i < numChildren; i++) { + const childID = currentElement.children[i]; + const child = ((this._idToElement.get(childID): any): Element); + const childWeight = child.isCollapsed ? 1 : child.weight; + + if (index <= currentWeight + childWeight) { + currentWeight++; + currentElement = child; + break; + } else { + currentWeight += childWeight; + } + } + } + + return ((currentElement: any): Element) || null; + } + + getElementIDAtIndex(index: number): number | null { + const element: Element | null = this.getElementAtIndex(index); + return element === null ? null : element.id; + } + + getElementByID(id: number): Element | null { + const element = this._idToElement.get(id); + if (element == null) { + console.warn(`No element found with id "${id}"`); + return null; + } + + return element; + } + + getIndexOfElementID(id: number): number | null { + const element = this.getElementByID(id); + + if (element === null || element.parentID === 0) { + return null; + } + + // Walk up the tree to the root. + // Increment the index by one for each node we encounter, + // and by the weight of all nodes to the left of the current one. + // This should be a relatively fast way of determining the index of a node within the tree. + let previousID = id; + let currentID = element.parentID; + let index = 0; + while (true) { + const current = ((this._idToElement.get(currentID): any): Element); + + const { children } = current; + for (let i = 0; i < children.length; i++) { + const childID = children[i]; + if (childID === previousID) { + break; + } + const child = ((this._idToElement.get(childID): any): Element); + index += child.isCollapsed ? 1 : child.weight; + } + + if (current.parentID === 0) { + // We found the root; stop crawling. + break; + } + + index++; + + previousID = current.id; + currentID = current.parentID; + } + + // At this point, the current ID is a root (from the previous loop). + // We also need to offset the index by previous root weights. + for (let i = 0; i < this._roots.length; i++) { + const rootID = this._roots[i]; + if (rootID === currentID) { + break; + } + const root = ((this._idToElement.get(rootID): any): Element); + index += root.weight; + } + + return index; + } + + getOwnersListForElement(ownerID: number): Array { + const list = []; + let element = this._idToElement.get(ownerID); + if (element != null) { + list.push({ + ...element, + depth: 0, + }); + + const unsortedIDs = this._ownersMap.get(ownerID); + if (unsortedIDs !== undefined) { + const depthMap: Map = new Map([[ownerID, 0]]); + + // Items in a set are ordered based on insertion. + // This does not correlate with their order in the tree. + // So first we need to order them. + // I wish we could avoid this sorting operation; we could sort at insertion time, + // but then we'd have to pay sorting costs even if the owners list was never used. + // Seems better to defer the cost, since the set of ids is probably pretty small. + const sortedIDs = Array.from(unsortedIDs).sort( + (idA, idB) => + ((this.getIndexOfElementID(idA): any): number) - + ((this.getIndexOfElementID(idB): any): number) + ); + + // Next we need to determine the appropriate depth for each element in the list. + // The depth in the list may not correspond to the depth in the tree, + // because the list has been filtered to remove intermediate components. + // Perhaps the easiest way to do this is to walk up the tree until we reach either: + // (1) another node that's already in the tree, or (2) the root (owner) + // at which point, our depth is just the depth of that node plus one. + sortedIDs.forEach(id => { + const element = this._idToElement.get(id); + if (element != null) { + let parentID = element.parentID; + + let depth = 0; + while (parentID > 0) { + if (parentID === ownerID || unsortedIDs.has(parentID)) { + depth = depthMap.get(parentID) + 1; + depthMap.set(id, depth); + break; + } + const parent = this._idToElement.get(parentID); + if (parent == null) { + break; + } + parentID = parent.parentID; + } + + if (depth === 0) { + throw Error('Invalid owners list'); + } + + list.push({ ...element, depth }); + } + }); + } + } + + return list; + } + + getRendererIDForElement(id: number): number | null { + let current = this._idToElement.get(id); + while (current != null) { + if (current.parentID === 0) { + const rendererID = this._rootIDToRendererID.get(current.id); + return rendererID == null ? null : rendererID; + } else { + current = this._idToElement.get(current.parentID); + } + } + return null; + } + + getRootIDForElement(id: number): number | null { + let current = this._idToElement.get(id); + while (current != null) { + if (current.parentID === 0) { + return current.id; + } else { + current = this._idToElement.get(current.parentID); + } + } + return null; + } + + isInsideCollapsedSubTree(id: number): boolean { + let current = this._idToElement.get(id); + while (current != null) { + if (current.parentID === 0) { + return false; + } else { + current = this._idToElement.get(current.parentID); + if (current != null && current.isCollapsed) { + return true; + } + } + } + return false; + } + + // TODO Maybe split this into two methods: expand() and collapse() + toggleIsCollapsed(id: number, isCollapsed: boolean): void { + let didMutate = false; + + const element = this.getElementByID(id); + if (element !== null) { + if (isCollapsed) { + if (element.type === ElementTypeRoot) { + throw Error('Root nodes cannot be collapsed'); + } + + if (!element.isCollapsed) { + didMutate = true; + element.isCollapsed = true; + + const weightDelta = 1 - element.weight; + + let parentElement = ((this._idToElement.get( + element.parentID + ): any): Element); + while (parentElement != null) { + // We don't need to break on a collapsed parent in the same way as the expand case below. + // That's because collapsing a node doesn't "bubble" and affect its parents. + parentElement.weight += weightDelta; + parentElement = this._idToElement.get(parentElement.parentID); + } + } + } else { + let currentElement = element; + while (currentElement != null) { + const oldWeight = currentElement.isCollapsed + ? 1 + : currentElement.weight; + + if (currentElement.isCollapsed) { + didMutate = true; + currentElement.isCollapsed = false; + + const newWeight = currentElement.isCollapsed + ? 1 + : currentElement.weight; + const weightDelta = newWeight - oldWeight; + + let parentElement = ((this._idToElement.get( + currentElement.parentID + ): any): Element); + while (parentElement != null) { + parentElement.weight += weightDelta; + if (parentElement.isCollapsed) { + // It's important to break on a collapsed parent when expanding nodes. + // That's because expanding a node "bubbles" up and expands all parents as well. + // Breaking in this case prevents us from over-incrementing the expanded weights. + break; + } + parentElement = this._idToElement.get(parentElement.parentID); + } + } + + currentElement = + currentElement.parentID !== 0 + ? this.getElementByID(currentElement.parentID) + : null; + } + } + + // Only re-calculate weights and emit an "update" event if the store was mutated. + if (didMutate) { + let weightAcrossRoots = 0; + this._roots.forEach(rootID => { + const { weight } = ((this.getElementByID(rootID): any): Element); + weightAcrossRoots += weight; + }); + this._weightAcrossRoots = weightAcrossRoots; + + // The Tree context's search reducer expects an explicit list of ids for nodes that were added or removed. + // In this case, we can pass it empty arrays since nodes in a collapsed tree are still there (just hidden). + // Updating the selected search index later may require auto-expanding a collapsed subtree though. + this.emit('mutated', [[], new Map()]); + } + } + } + + _adjustParentTreeWeight = ( + parentElement: Element | null, + weightDelta: number + ) => { + let isInsideCollapsedSubTree = false; + + while (parentElement != null) { + parentElement.weight += weightDelta; + + // Additions and deletions within a collapsed subtree should not bubble beyond the collapsed parent. + // Their weight will bubble up when the parent is expanded. + if (parentElement.isCollapsed) { + isInsideCollapsedSubTree = true; + break; + } + + parentElement = ((this._idToElement.get( + parentElement.parentID + ): any): Element); + } + + // Additions and deletions within a collapsed subtree should not affect the overall number of elements. + if (!isInsideCollapsedSubTree) { + this._weightAcrossRoots += weightDelta; + } + }; + + onBridgeNativeStyleEditorSupported = ({ + isSupported, + validAttributes, + }: {| + isSupported: boolean, + validAttributes: ?$ReadOnlyArray, + |}) => { + this._isNativeStyleEditorSupported = isSupported; + this._nativeStyleEditorValidAttributes = validAttributes || null; + + this.emit('supportsNativeStyleEditor'); + }; + + onBridgeOperations = (operations: Array) => { + if (__DEBUG__) { + console.groupCollapsed('onBridgeOperations'); + debug('onBridgeOperations', operations.join(',')); + } + + let haveRootsChanged = false; + + // The first two values are always rendererID and rootID + const rendererID = operations[0]; + + const addedElementIDs: Array = []; + // This is a mapping of removed ID -> parent ID: + const removedElementIDs: Map = new Map(); + // We'll use the parent ID to adjust selection if it gets deleted. + + let i = 2; + + // Reassemble the string table. + const stringTable = [ + null, // ID = 0 corresponds to the null string. + ]; + const stringTableSize = operations[i++]; + const stringTableEnd = i + stringTableSize; + while (i < stringTableEnd) { + const nextLength = operations[i++]; + const nextString = utfDecodeString( + (operations.slice(i, i + nextLength): any) + ); + stringTable.push(nextString); + i += nextLength; + } + + while (i < operations.length) { + const operation = operations[i]; + switch (operation) { + case TREE_OPERATION_ADD: { + const id = ((operations[i + 1]: any): number); + const type = ((operations[i + 2]: any): ElementType); + + i = i + 3; + + if (this._idToElement.has(id)) { + throw Error( + `Cannot add node ${id} because a node with that id is already in the Store.` + ); + } + + let ownerID: number = 0; + let parentID: number = ((null: any): number); + if (type === ElementTypeRoot) { + if (__DEBUG__) { + debug('Add', `new root node ${id}`); + } + + const supportsProfiling = operations[i] > 0; + i++; + + const hasOwnerMetadata = operations[i] > 0; + i++; + + this._roots = this._roots.concat(id); + this._rootIDToRendererID.set(id, rendererID); + this._rootIDToCapabilities.set(id, { + hasOwnerMetadata, + supportsProfiling, + }); + + this._idToElement.set(id, { + children: [], + depth: -1, + displayName: null, + hocDisplayNames: null, + id, + isCollapsed: false, // Never collapse roots; it would hide the entire tree. + key: null, + ownerID: 0, + parentID: 0, + type, + weight: 0, + }); + + haveRootsChanged = true; + } else { + parentID = ((operations[i]: any): number); + i++; + + ownerID = ((operations[i]: any): number); + i++; + + const displayNameStringID = operations[i]; + const displayName = stringTable[displayNameStringID]; + i++; + + const keyStringID = operations[i]; + const key = stringTable[keyStringID]; + i++; + + if (__DEBUG__) { + debug( + 'Add', + `node ${id} (${displayName || 'null'}) as child of ${parentID}` + ); + } + + if (!this._idToElement.has(parentID)) { + throw Error( + `Cannot add child ${id} to parent ${parentID} because parent node was not found in the Store.` + ); + } + + const parentElement = ((this._idToElement.get( + parentID + ): any): Element); + parentElement.children.push(id); + + const [ + displayNameWithoutHOCs, + hocDisplayNames, + ] = separateDisplayNameAndHOCs(displayName, type); + + const element: Element = { + children: [], + depth: parentElement.depth + 1, + displayName: displayNameWithoutHOCs, + hocDisplayNames, + id, + isCollapsed: this._collapseNodesByDefault, + key, + ownerID, + parentID: parentElement.id, + type, + weight: 1, + }; + + this._idToElement.set(id, element); + addedElementIDs.push(id); + this._adjustParentTreeWeight(parentElement, 1); + + if (ownerID > 0) { + let set = this._ownersMap.get(ownerID); + if (set === undefined) { + set = new Set(); + this._ownersMap.set(ownerID, set); + } + set.add(id); + } + } + break; + } + case TREE_OPERATION_REMOVE: { + const removeLength = ((operations[i + 1]: any): number); + i = i + 2; + + for (let removeIndex = 0; removeIndex < removeLength; removeIndex++) { + const id = ((operations[i]: any): number); + + if (!this._idToElement.has(id)) { + throw Error( + `Cannot remove node ${id} because no matching node was found in the Store.` + ); + } + + i = i + 1; + + const element = ((this._idToElement.get(id): any): Element); + const { children, ownerID, parentID, weight } = element; + if (children.length > 0) { + throw new Error(`Node ${id} was removed before its children.`); + } + + this._idToElement.delete(id); + + let parentElement = null; + if (parentID === 0) { + if (__DEBUG__) { + debug('Remove', `node ${id} root`); + } + + this._roots = this._roots.filter(rootID => rootID !== id); + this._rootIDToRendererID.delete(id); + this._rootIDToCapabilities.delete(id); + + haveRootsChanged = true; + } else { + if (__DEBUG__) { + debug('Remove', `node ${id} from parent ${parentID}`); + } + parentElement = ((this._idToElement.get(parentID): any): Element); + if (parentElement === undefined) { + throw Error( + `Cannot remove node ${id} from parent ${parentID} because no matching node was found in the Store.` + ); + } + const index = parentElement.children.indexOf(id); + parentElement.children.splice(index, 1); + } + + this._adjustParentTreeWeight(parentElement, -weight); + removedElementIDs.set(id, parentID); + + this._ownersMap.delete(id); + if (ownerID > 0) { + const set = this._ownersMap.get(ownerID); + if (set !== undefined) { + set.delete(id); + } + } + } + break; + } + case TREE_OPERATION_REORDER_CHILDREN: { + const id = ((operations[i + 1]: any): number); + const numChildren = ((operations[i + 2]: any): number); + i = i + 3; + + if (!this._idToElement.has(id)) { + throw Error( + `Cannot reorder children for node ${id} because no matching node was found in the Store.` + ); + } + + const element = ((this._idToElement.get(id): any): Element); + const children = element.children; + if (children.length !== numChildren) { + throw Error( + `Children cannot be added or removed during a reorder operation.` + ); + } + + for (let j = 0; j < numChildren; j++) { + const childID = operations[i + j]; + children[j] = childID; + if (__DEV__) { + // This check is more expensive so it's gated by __DEV__. + const childElement = this._idToElement.get(childID); + if (childElement == null || childElement.parentID !== id) { + console.error( + `Children cannot be added or removed during a reorder operation.` + ); + } + } + } + i = i + numChildren; + + if (__DEBUG__) { + debug('Re-order', `Node ${id} children ${children.join(',')}`); + } + break; + } + case TREE_OPERATION_UPDATE_TREE_BASE_DURATION: + // Base duration updates are only sent while profiling is in progress. + // We can ignore them at this point. + // The profiler UI uses them lazily in order to generate the tree. + i = i + 3; + break; + default: + throw Error(`Unsupported Bridge operation ${operation}`); + } + } + + this._revision++; + + if (haveRootsChanged) { + const prevSupportsProfiling = this._supportsProfiling; + + this._hasOwnerMetadata = false; + this._supportsProfiling = false; + this._rootIDToCapabilities.forEach( + ({ hasOwnerMetadata, supportsProfiling }) => { + if (hasOwnerMetadata) { + this._hasOwnerMetadata = true; + } + if (supportsProfiling) { + this._supportsProfiling = true; + } + } + ); + + this.emit('roots'); + + if (this._supportsProfiling !== prevSupportsProfiling) { + this.emit('supportsProfiling'); + } + } + + if (__DEBUG__) { + console.log(printStore(this, true)); + console.groupEnd(); + } + + this.emit('mutated', [addedElementIDs, removedElementIDs]); + }; + + // Certain backends save filters on a per-domain basis. + // In order to prevent filter preferences and applied filters from being out of sync, + // this message enables the backend to override the frontend's current ("saved") filters. + // This action should also override the saved filters too, + // else reloading the frontend without reloading the backend would leave things out of sync. + onBridgeOverrideComponentFilters = ( + componentFilters: Array + ) => { + this._componentFilters = componentFilters; + + saveComponentFilters(componentFilters); + }; + + onBridgeShutdown = () => { + if (__DEBUG__) { + debug('onBridgeShutdown', 'unsubscribing from Bridge'); + } + + this._bridge.removeListener('operations', this.onBridgeOperations); + this._bridge.removeListener('shutdown', this.onBridgeShutdown); + this._bridge.removeListener( + 'isBackendStorageAPISupported', + this.onBridgeStorageSupported + ); + }; + + onBridgeStorageSupported = (isBackendStorageAPISupported: boolean) => { + this._isBackendStorageAPISupported = isBackendStorageAPISupported; + + this.emit('supportsReloadAndProfile'); + }; +} diff --git a/extension/src/devtools/views/Button.css b/extension/src/devtools/views/Button.css new file mode 100644 index 0000000000000..d3885372b485e --- /dev/null +++ b/extension/src/devtools/views/Button.css @@ -0,0 +1,37 @@ +.Button { + border: none; + background: var(--color-button-background); + color: var(--color-button); + padding: 0; + border-radius: 0.25rem; + flex: 0 0 auto; +} +.ButtonContent { + display: inline-flex; + align-items: center; + border-radius: 0.25rem; + padding: 0.25rem; +} + +.Button:hover { + color: var(--color-button-hover); +} +.Button:active { + color: var(--color-button-focus); + outline: none; +} +.Button:focus, +.ButtonContent:focus { + outline: none; +} + +.Button:focus > .ButtonContent { + background: var(--color-button-background-focus); +} + +.Button:disabled, +.Button:disabled:active { + background: var(--color-button-background); + color: var(--color-button-disabled); + cursor: default; +} diff --git a/extension/src/devtools/views/Button.js b/extension/src/devtools/views/Button.js new file mode 100644 index 0000000000000..bb55bca22f69c --- /dev/null +++ b/extension/src/devtools/views/Button.js @@ -0,0 +1,38 @@ +// @flow + +import React from 'react'; +import Tooltip from '@reach/tooltip'; + +import styles from './Button.css'; +import tooltipStyles from './Tooltip.css'; + +type Props = { + children: React$Node, + className?: string, + title?: string, +}; + +export default function Button({ + children, + className = '', + title = '', + ...rest +}: Props) { + let button = ( + + ); + + if (title) { + button = ( + + {button} + + ); + } + + return button; +} diff --git a/extension/src/devtools/views/ButtonIcon.css b/extension/src/devtools/views/ButtonIcon.css new file mode 100644 index 0000000000000..b21c094828703 --- /dev/null +++ b/extension/src/devtools/views/ButtonIcon.css @@ -0,0 +1,5 @@ +.ButtonIcon { + width: 1rem; + height: 1rem; + fill: currentColor; +} diff --git a/extension/src/devtools/views/ButtonIcon.js b/extension/src/devtools/views/ButtonIcon.js new file mode 100644 index 0000000000000..6c3887fe66645 --- /dev/null +++ b/extension/src/devtools/views/ButtonIcon.js @@ -0,0 +1,240 @@ +// @flow + +import React from 'react'; +import styles from './ButtonIcon.css'; + +export type IconType = + | 'add' + | 'cancel' + | 'clear' + | 'close' + | 'collapsed' + | 'copy' + | 'delete' + | 'down' + | 'expanded' + | 'export' + | 'filter' + | 'import' + | 'log-data' + | 'more' + | 'next' + | 'previous' + | 'record' + | 'reload' + | 'save' + | 'search' + | 'settings' + | 'suspend' + | 'undo' + | 'up' + | 'view-dom' + | 'view-source'; + +type Props = {| + className?: string, + type: IconType, +|}; + +export default function ButtonIcon({ className = '', type }: Props) { + let pathData = null; + switch (type) { + case 'add': + pathData = PATH_ADD; + break; + case 'cancel': + pathData = PATH_CANCEL; + break; + case 'clear': + pathData = PATH_CLEAR; + break; + case 'close': + pathData = PATH_CLOSE; + break; + case 'collapsed': + pathData = PATH_COLLAPSED; + break; + case 'copy': + pathData = PATH_COPY; + break; + case 'delete': + pathData = PATH_DELETE; + break; + case 'down': + pathData = PATH_DOWN; + break; + case 'expanded': + pathData = PATH_EXPANDED; + break; + case 'export': + pathData = PATH_EXPORT; + break; + case 'filter': + pathData = PATH_FILTER; + break; + case 'import': + pathData = PATH_IMPORT; + break; + case 'log-data': + pathData = PATH_LOG_DATA; + break; + case 'more': + pathData = PATH_MORE; + break; + case 'next': + pathData = PATH_NEXT; + break; + case 'previous': + pathData = PATH_PREVIOUS; + break; + case 'record': + pathData = PATH_RECORD; + break; + case 'reload': + pathData = PATH_RELOAD; + break; + case 'save': + pathData = PATH_SAVE; + break; + case 'search': + pathData = PATH_SEARCH; + break; + case 'settings': + pathData = PATH_SETTINGS; + break; + case 'suspend': + pathData = PATH_SUSPEND; + break; + case 'undo': + pathData = PATH_UNDO; + break; + case 'up': + pathData = PATH_UP; + break; + case 'view-dom': + pathData = PATH_VIEW_DOM; + break; + case 'view-source': + pathData = PATH_VIEW_SOURCE; + break; + default: + console.warn(`Unsupported type "${type}" specified for ButtonIcon`); + break; + } + + return ( + + + + + ); +} + +const PATH_ADD = + 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z'; + +const PATH_CANCEL = ` + M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z +`; + +const PATH_CLEAR = ` + M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 + 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z +`; + +const PATH_CLOSE = + 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'; + +const PATH_COLLAPSED = 'M10 17l5-5-5-5v10z'; + +const PATH_COPY = ` + M3 13h2v-2H3v2zm0 4h2v-2H3v2zm2 4v-2H3a2 2 0 0 0 2 2zM3 9h2V7H3v2zm12 12h2v-2h-2v2zm4-18H9a2 2 0 0 0-2 + 2v10a2 2 0 0 0 2 2h10c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H9V5h10v10zm-8 6h2v-2h-2v2zm-4 0h2v-2H7v2z +`; + +const PATH_DELETE = ` + M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 + 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z +`; + +const PATH_DOWN = 'M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z'; + +const PATH_EXPANDED = 'M7 10l5 5 5-5z'; + +const PATH_EXPORT = 'M15.82,2.14v7H21l-9,9L3,9.18H8.18v-7ZM3,20.13H21v1.73H3Z'; + +const PATH_FILTER = 'M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z'; + +const PATH_IMPORT = 'M8.18,18.13v-7H3l9-8.95,9,9H15.82v7ZM3,20.13H21v1.73H3Z'; + +const PATH_LOG_DATA = ` + M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 + 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 + 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 + 8h-4v-2h4v2zm0-4h-4v-2h4v2z +`; + +const PATH_MORE = ` + M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 + 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z +`; + +const PATH_NEXT = 'M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z'; + +const PATH_PREVIOUS = + 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z'; + +const PATH_RECORD = 'M4,12a8,8 0 1,0 16,0a8,8 0 1,0 -16,0'; + +const PATH_RELOAD = ` + M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 + 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 + 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z +`; + +const PATH_SAVE = ` + M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z +`; + +const PATH_SEARCH = ` + M8.5,22H3.7l-1.4-1.5V3.8l1.3-1.5h17.2l1,1.5v4.9h-1.3V4.3l-0.4-0.6H4.2L3.6,4.3V20l0.7,0.7h4.2V22z + M23,13.9l-4.6,3.6l4.6,4.6l-1.1,1.1l-4.7-4.4l-3.3,4.4l-3.2-12.3L23,13.9z +`; + +const PATH_SETTINGS = ` + M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 + 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 + 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 + 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 + 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 + 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 + 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z +`; + +const PATH_SUSPEND = ` + M15 1H9v2h6V1zm-4 13h2V8h-2v6zm8.03-6.61l1.42-1.42c-.43-.51-.9-.99-1.41-1.41l-1.42 1.42C16.07 4.74 14.12 4 12 4c-4.97 + 0-9 4.03-9 9s4.02 9 9 9 9-4.03 9-9c0-2.12-.74-4.07-1.97-5.61zM12 20c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z +`; + +const PATH_UNDO = ` + M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 + 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z +`; + +const PATH_UP = 'M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z'; + +const PATH_VIEW_DOM = ` + M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 + 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 + 3-1.34 3-3-1.34-3-3-3z +`; + +const PATH_VIEW_SOURCE = ` + M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z + `; diff --git a/extension/src/devtools/views/Components/Badge.css b/extension/src/devtools/views/Components/Badge.css new file mode 100644 index 0000000000000..22f749cc5b1a1 --- /dev/null +++ b/extension/src/devtools/views/Components/Badge.css @@ -0,0 +1,17 @@ +.Badge { + display: inline-block; + background-color: var(--color-component-badge-background); + color: var(--color-text); + padding: 0.125rem 0.25rem; + line-height: normal; + border-radius: 0.125rem; + margin-right: 0.25rem; + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-small); +} + +.ExtraLabel { + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-small); + color: var(--color-component-badge-count); +} diff --git a/extension/src/devtools/views/Components/Badge.js b/extension/src/devtools/views/Components/Badge.js new file mode 100644 index 0000000000000..76ac7bf6246e1 --- /dev/null +++ b/extension/src/devtools/views/Components/Badge.js @@ -0,0 +1,47 @@ +// @flow + +import React, { Fragment } from 'react'; +import { ElementTypeMemo, ElementTypeForwardRef } from 'src/types'; +import styles from './Badge.css'; + +import type { ElementType } from 'src/types'; + +type Props = {| + className?: string, + hocDisplayNames: Array | null, + type: ElementType, +|}; + +export default function Badge({ className, hocDisplayNames, type }: Props) { + let hocDisplayName = null; + let totalBadgeCount = 0; + let typeLabel = null; + + if (hocDisplayNames !== null) { + hocDisplayName = hocDisplayNames[0]; + totalBadgeCount += hocDisplayNames.length; + } + + if (type === ElementTypeMemo) { + typeLabel = 'Memo'; + totalBadgeCount++; + } else if (type === ElementTypeForwardRef) { + typeLabel = 'ForwardRef'; + totalBadgeCount++; + } + + if (hocDisplayNames === null && typeLabel === null) { + return null; + } + + return ( + +
+ {hocDisplayName || typeLabel} +
+ {totalBadgeCount > 1 && ( +
+{totalBadgeCount - 1}
+ )} +
+ ); +} diff --git a/extension/src/devtools/views/Components/Components.css b/extension/src/devtools/views/Components/Components.css new file mode 100644 index 0000000000000..f37b3eaa73d3f --- /dev/null +++ b/extension/src/devtools/views/Components/Components.css @@ -0,0 +1,45 @@ +.Components { + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: row; + background-color: var(--color-background); + color: var(--color-text); + font-family: var(--font-family-sans); +} + +.TreeWrapper { + flex: 0 0 65%; + overflow: auto; +} + +.SelectedElementWrapper { + flex: 0 0 35%; + overflow-x: hidden; + overflow-y: auto; +} + +@media screen and (max-width: 600px) { + .Components { + flex-direction: column; + } + + .TreeWrapper { + flex: 0 0 50%; + } + + .SelectedElementWrapper { + flex: 0 0 50%; + } +} + +.Loading { + height: 100%; + padding-left: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-sans-large); + color: var(--color-dim); +} diff --git a/extension/src/devtools/views/Components/Components.js b/extension/src/devtools/views/Components/Components.js new file mode 100644 index 0000000000000..2623041e0c4a5 --- /dev/null +++ b/extension/src/devtools/views/Components/Components.js @@ -0,0 +1,46 @@ +// @flow + +import React, { Suspense } from 'react'; +import Tree from './Tree'; +import SelectedElement from './SelectedElement'; +import { InspectedElementContextController } from './InspectedElementContext'; +import { NativeStyleContextController } from './NativeStyleEditor/context'; +import { OwnersListContextController } from './OwnersListContext'; +import portaledContent from '../portaledContent'; +import { ModalDialog } from '../ModalDialog'; +import SettingsModal from 'src/devtools/views/Settings/SettingsModal'; +import { SettingsModalContextController } from 'src/devtools/views/Settings/SettingsModalContext'; + +import styles from './Components.css'; + +function Components(_: {||}) { + // TODO Flex wrappers below should be user resizable. + return ( + + + +
+
+ +
+
+ + }> + + + +
+ + +
+
+
+
+ ); +} + +function Loading() { + return
Loading...
; +} + +export default portaledContent(Components); diff --git a/extension/src/devtools/views/Components/EditableValue.css b/extension/src/devtools/views/Components/EditableValue.css new file mode 100644 index 0000000000000..3087b852617e0 --- /dev/null +++ b/extension/src/devtools/views/Components/EditableValue.css @@ -0,0 +1,30 @@ +.CheckboxLabel { + flex: 1 1 100%; + display: flex; +} +.CheckboxLabel:focus-within { + background-color: var(--color-button-background-focus); +} + +.Checkbox:focus { + outline: none; +} + +.Input { + flex: 1 1; + background: none; + border: 1px solid transparent; + color: var(--color-attribute-editable-value); + border-radius: 0.125rem; + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-normal); +} +.Input:focus { + background-color: var(--color-button-background-focus); + outline: none; +} + +.ResetButton { + flex: 0 0 auto; + padding: 0 0.5rem; +} diff --git a/extension/src/devtools/views/Components/EditableValue.js b/extension/src/devtools/views/Components/EditableValue.js new file mode 100644 index 0000000000000..3238315a1572a --- /dev/null +++ b/extension/src/devtools/views/Components/EditableValue.js @@ -0,0 +1,139 @@ +// @flow + +import React, { Fragment, useCallback, useRef, useState } from 'react'; +import Button from '../Button'; +import ButtonIcon from '../ButtonIcon'; +import styles from './EditableValue.css'; + +type OverrideValueFn = (path: Array, value: any) => void; + +type EditableValueProps = {| + dataType: string, + overrideValueFn: OverrideValueFn, + path: Array, + value: any, +|}; + +export default function EditableValue({ + dataType, + overrideValueFn, + path, + value, +}: EditableValueProps) { + const [hasPendingChanges, setHasPendingChanges] = useState(false); + const [editableValue, setEditableValue] = useState(value); + const inputRef = useRef(null); + + if (hasPendingChanges && editableValue === value) { + setHasPendingChanges(false); + } + + const handleChange = useCallback( + ({ target }) => { + if (dataType === 'boolean') { + setEditableValue(target.checked); + overrideValueFn(path, target.checked); + } else { + setEditableValue(target.value); + } + setHasPendingChanges(true); + }, + [dataType, overrideValueFn, path] + ); + + const handleReset = useCallback(() => { + setEditableValue(value); + setHasPendingChanges(false); + + if (inputRef.current !== null) { + inputRef.current.focus(); + } + }, [value]); + + const handleKeyDown = useCallback( + event => { + // Prevent keydown events from e.g. change selected element in the tree + event.stopPropagation(); + + const { key } = event; + + if (key === 'Enter') { + if (dataType === 'number') { + const parsedValue = parseFloat(editableValue); + if (!Number.isNaN(parsedValue)) { + overrideValueFn(path, parsedValue); + } + } else { + overrideValueFn(path, editableValue); + } + + // Don't reset the pending change flag here. + // The inspected fiber won't be updated until after the next "inspectElement" message. + // We'll reset that flag during a subsequent render. + } else if (key === 'Escape') { + setEditableValue(value); + setHasPendingChanges(false); + } + }, + [editableValue, dataType, overrideValueFn, path, value] + ); + + // Render different input types based on the dataType + let type = 'text'; + if (dataType === 'boolean') { + type = 'checkbox'; + } else if (dataType === 'number') { + type = 'number'; + } + + let inputValue = value == null ? '' : value; + if (hasPendingChanges) { + inputValue = editableValue == null ? '' : editableValue; + } + + let placeholder = ''; + if (value === null) { + placeholder = '(null)'; + } else if (value === undefined) { + placeholder = '(undefined)'; + } else if (dataType === 'string') { + placeholder = '(string)'; + } + + return ( + + {dataType === 'boolean' && ( + + )} + {dataType !== 'boolean' && ( + + )} + {hasPendingChanges && dataType !== 'boolean' && ( + + )} + + ); +} diff --git a/extension/src/devtools/views/Components/Element.css b/extension/src/devtools/views/Components/Element.css new file mode 100644 index 0000000000000..f71e9f479f476 --- /dev/null +++ b/extension/src/devtools/views/Components/Element.css @@ -0,0 +1,75 @@ +.Element, +.InactiveSelectedElement, +.SelectedElement, +.HoveredElement { + color: var(--color-component-name); +} +.HoveredElement { + background-color: var(--color-background-hover); +} +.InactiveSelectedElement { + background-color: var(--color-background-inactive); +} + +.Wrapper { + padding: 0 0.25rem; + white-space: nowrap; + height: var(--line-height-data); + line-height: var(--line-height-data); + display: inline-flex; + align-items: center; + cursor: default; + user-select: none; +} + +.ScrollAnchor { + height: 100%; + width: 0; +} + +.SelectedElement { + background-color: var(--color-background-selected); + color: var(--color-text-selected); + + /* Invert colors */ + --color-component-name: var(--color-component-name-inverted); + --color-text: var(--color-text-selected); + --color-component-badge-background: var( + --color-component-badge-background-inverted + ); + --color-component-badge-count: var(--color-component-badge-count-inverted); + --color-attribute-name: var(--color-attribute-name-inverted); + --color-attribute-value: var(--color-attribute-value-inverted); + --color-expand-collapse-toggle: var(--color-component-name-inverted); +} + +.KeyName { + color: var(--color-attribute-name); +} + +.KeyValue { + color: var(--color-attribute-value); + user-select: text; + max-width: 100px; + overflow-x: hidden; + text-overflow: ellipsis; +} + +.Highlight { + background-color: var(--color-search-match); +} +.CurrentHighlight { + background-color: var(--color-search-match-current); +} + +.ExpandCollapseToggle { + display: inline-flex; + width: 1rem; + height: 1rem; + flex: 0 0 1rem; + color: var(--color-expand-collapse-toggle); +} + +.Badge { + margin-left: 0.25rem; +} diff --git a/extension/src/devtools/views/Components/Element.js b/extension/src/devtools/views/Components/Element.js new file mode 100644 index 0000000000000..dc7f52c065a5d --- /dev/null +++ b/extension/src/devtools/views/Components/Element.js @@ -0,0 +1,231 @@ +// @flow + +import React, { Fragment, useContext, useMemo, useState } from 'react'; +import Store from 'src/devtools/store'; +import Badge from './Badge'; +import ButtonIcon from '../ButtonIcon'; +import { createRegExp } from '../utils'; +import { TreeDispatcherContext, TreeStateContext } from './TreeContext'; +import { StoreContext } from '../context'; + +import type { ItemData } from './Tree'; +import type { Element } from './types'; + +import styles from './Element.css'; + +type Props = { + data: ItemData, + index: number, + style: Object, +}; + +export default function ElementView({ data, index, style }: Props) { + const store = useContext(StoreContext); + const { ownerFlatTree, ownerID, selectedElementID } = useContext( + TreeStateContext + ); + const dispatch = useContext(TreeDispatcherContext); + + const element = + ownerFlatTree !== null + ? ownerFlatTree[index] + : store.getElementAtIndex(index); + + const [isHovered, setIsHovered] = useState(false); + + const { isNavigatingWithKeyboard, onElementMouseEnter, treeFocused } = data; + const id = element === null ? null : element.id; + const isSelected = selectedElementID === id; + + const handleDoubleClick = () => { + if (id !== null) { + dispatch({ type: 'SELECT_OWNER', payload: id }); + } + }; + + const handleMouseDown = ({ metaKey }) => { + if (id !== null) { + dispatch({ + type: 'SELECT_ELEMENT_BY_ID', + payload: metaKey ? null : id, + }); + } + }; + + const handleMouseEnter = () => { + setIsHovered(true); + if (id !== null) { + onElementMouseEnter(id); + } + }; + + const handleMouseLeave = () => { + setIsHovered(false); + }; + + const handleKeyDoubleClick = event => { + // Double clicks on key value are used for text selection (if the text has been truncated). + // They should not enter the owners tree view. + event.stopPropagation(); + event.preventDefault(); + }; + + // Handle elements that are removed from the tree while an async render is in progress. + if (element == null) { + console.warn(` Could not find element at index ${index}`); + + // This return needs to happen after hooks, since hooks can't be conditional. + return null; + } + + const { + depth, + displayName, + hocDisplayNames, + key, + type, + } = ((element: any): Element); + + let className = styles.Element; + if (isSelected) { + className = treeFocused + ? styles.SelectedElement + : styles.InactiveSelectedElement; + } else if (isHovered && !isNavigatingWithKeyboard) { + className = styles.HoveredElement; + } + + return ( +
+ {/* This wrapper is used by Tree for measurement purposes. */} +
+ {ownerID === null ? ( + + ) : null} + + {key && ( + +  key=" + + {key} + + " + + )} + +
+
+ ); +} + +// Prevent double clicks on toggle from drilling into the owner list. +const swallowDoubleClick = event => { + event.preventDefault(); + event.stopPropagation(); +}; + +type ExpandCollapseToggleProps = {| + element: Element, + store: Store, +|}; + +function ExpandCollapseToggle({ element, store }: ExpandCollapseToggleProps) { + const { children, id, isCollapsed } = element; + + const toggleCollapsed = event => { + event.preventDefault(); + event.stopPropagation(); + + store.toggleIsCollapsed(id, !isCollapsed); + }; + + const stopPropagation = event => { + // Prevent the row from selecting + event.stopPropagation(); + }; + + if (children.length === 0) { + return
; + } + + return ( +
+ +
+ ); +} + +type DisplayNameProps = {| + displayName: string | null, + id: number, +|}; + +function DisplayName({ displayName, id }: DisplayNameProps) { + const { searchIndex, searchResults, searchText } = useContext( + TreeStateContext + ); + const isSearchResult = useMemo(() => { + return searchResults.includes(id); + }, [id, searchResults]); + const isCurrentResult = + searchIndex !== null && id === searchResults[searchIndex]; + + if (!isSearchResult || displayName === null) { + return displayName; + } + + const match = createRegExp(searchText).exec(displayName); + + if (match === null) { + return displayName; + } + + const startIndex = match.index; + const stopIndex = startIndex + match[0].length; + + const children = []; + if (startIndex > 0) { + children.push({displayName.slice(0, startIndex)}); + } + children.push( + + {displayName.slice(startIndex, stopIndex)} + + ); + if (stopIndex < displayName.length) { + children.push({displayName.slice(stopIndex)}); + } + + return children; +} diff --git a/extension/src/devtools/views/Components/ExpandCollapseToggle.css b/extension/src/devtools/views/Components/ExpandCollapseToggle.css new file mode 100644 index 0000000000000..38b2aa6f7e5ca --- /dev/null +++ b/extension/src/devtools/views/Components/ExpandCollapseToggle.css @@ -0,0 +1,7 @@ +.ExpandCollapseToggle { + flex: 0 0 1rem; + width: 1rem; + height: 1rem; + padding: 0; + color: var(--color-expand-collapse-toggle); +} diff --git a/extension/src/devtools/views/Components/ExpandCollapseToggle.js b/extension/src/devtools/views/Components/ExpandCollapseToggle.js new file mode 100644 index 0000000000000..61fdca7f33d26 --- /dev/null +++ b/extension/src/devtools/views/Components/ExpandCollapseToggle.js @@ -0,0 +1,27 @@ +// @flow + +import React from 'react'; +import Button from '../Button'; +import ButtonIcon from '../ButtonIcon'; + +import styles from './ExpandCollapseToggle.css'; + +type ExpandCollapseToggleProps = {| + isOpen: boolean, + setIsOpen: Function, +|}; + +export default function ExpandCollapseToggle({ + isOpen, + setIsOpen, +}: ExpandCollapseToggleProps) { + return ( + + ); +} diff --git a/extension/src/devtools/views/Components/HocBadges.css b/extension/src/devtools/views/Components/HocBadges.css new file mode 100644 index 0000000000000..4b37be0325508 --- /dev/null +++ b/extension/src/devtools/views/Components/HocBadges.css @@ -0,0 +1,16 @@ +.HocBadges { + padding: 0.125rem 0.25rem; + user-select: none; +} + +.Badge { + display: inline-block; + background-color: var(--color-component-badge-background); + color: var(--color-text); + padding: 0.125rem 0.25rem; + line-height: normal; + border-radius: 0.125rem; + margin-right: 0.25rem; + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-small); +} diff --git a/extension/src/devtools/views/Components/HocBadges.js b/extension/src/devtools/views/Components/HocBadges.js new file mode 100644 index 0000000000000..6cd329e8266a0 --- /dev/null +++ b/extension/src/devtools/views/Components/HocBadges.js @@ -0,0 +1,38 @@ +// @flow + +import React from 'react'; +import { ElementTypeForwardRef, ElementTypeMemo } from 'src/types'; +import styles from './HocBadges.css'; + +import type { Element } from './types'; + +type Props = {| + element: Element, +|}; + +export default function HocBadges({ element }: Props) { + const { hocDisplayNames, type } = ((element: any): Element); + + let typeBadge = null; + if (type === ElementTypeMemo) { + typeBadge = 'Memo'; + } else if (type === ElementTypeForwardRef) { + typeBadge = 'ForwardRef'; + } + + if (hocDisplayNames === null && typeBadge === null) { + return null; + } + + return ( +
+ {typeBadge !== null &&
{typeBadge}
} + {hocDisplayNames !== null && + hocDisplayNames.map(hocDisplayName => ( +
+ {hocDisplayName} +
+ ))} +
+ ); +} diff --git a/extension/src/devtools/views/Components/HooksTree.css b/extension/src/devtools/views/Components/HooksTree.css new file mode 100644 index 0000000000000..1304c276cc711 --- /dev/null +++ b/extension/src/devtools/views/Components/HooksTree.css @@ -0,0 +1,69 @@ +.HooksTreeView { + padding: 0.25rem; + border-top: 1px solid var(--color-border); +} + +.Hook { +} + +.Children { + padding-left: 1rem; +} + +.HeaderRow { + display: flex; + align-items: center; +} + +.Header { + flex: 1 1; + font-family: var(--font-family-sans); +} + +.NameValueRow { + display: flex; +} + +.Name, +.NameAnonymous { + flex: 0 0 auto; + user-select: none; +} +.Name { + color: var(--color-dim); +} +.NameAnonymous { + color: var(--color-dimmer); +} + +.EditableName { + color: var(--color-attribute-name); + flex: 0 0 auto; + user-select: none; +} +.EditableName:after, +.Name:after { + color: var(--color-text); + content: ': '; + margin-right: 0.5rem; +} + +.Value { + color: var(--color-attribute-value); + overflow: hidden; + text-overflow: ellipsis; +} + +.None { + color: var(--color-dimmer); + font-style: italic; +} + +.TruncationIndicator { + color: var(--color-dimmer); +} + +.ExpandCollapseToggleSpacer { + flex: 0 0 1rem; + width: 1rem; +} diff --git a/extension/src/devtools/views/Components/HooksTree.js b/extension/src/devtools/views/Components/HooksTree.js new file mode 100644 index 0000000000000..10df0c470a65e --- /dev/null +++ b/extension/src/devtools/views/Components/HooksTree.js @@ -0,0 +1,286 @@ +// @flow + +import { copy } from 'clipboard-js'; +import React, { useCallback, useContext, useState } from 'react'; +import { BridgeContext, StoreContext } from '../context'; +import Button from '../Button'; +import ButtonIcon from '../ButtonIcon'; +import EditableValue from './EditableValue'; +import ExpandCollapseToggle from './ExpandCollapseToggle'; +import { InspectedElementContext } from './InspectedElementContext'; +import KeyValue from './KeyValue'; +import { serializeHooksForCopy } from '../utils'; +import styles from './HooksTree.css'; +import { meta } from '../../../hydration'; + +import type { InspectPath } from './SelectedElement'; +import type { HooksNode, HooksTree } from 'src/backend/types'; + +type HooksTreeViewProps = {| + canEditHooks: boolean, + hooks: HooksTree | null, + id: number, +|}; + +export function HooksTreeView({ canEditHooks, hooks, id }: HooksTreeViewProps) { + const { getInspectedElementPath } = useContext(InspectedElementContext); + const inspectPath = useCallback( + (path: Array) => { + getInspectedElementPath(id, ['hooks', ...path]); + }, + [getInspectedElementPath, id] + ); + const handleCopy = useCallback(() => copy(serializeHooksForCopy(hooks)), [ + hooks, + ]); + + if (hooks === null) { + return null; + } else { + return ( +
+
+
hooks
+ +
+ +
+ ); + } +} + +type InnerHooksTreeViewProps = {| + canEditHooks: boolean, + hooks: HooksTree, + id: number, + inspectPath: InspectPath, + path: Array, +|}; + +export function InnerHooksTreeView({ + canEditHooks, + hooks, + id, + inspectPath, + path, +}: InnerHooksTreeViewProps) { + // $FlowFixMe "Missing type annotation for U" whatever that means + return hooks.map((hook, index) => ( + + )); +} + +type HookViewProps = {| + canEditHooks: boolean, + hook: HooksNode, + id: number, + inspectPath: InspectPath, + path: Array, +|}; + +function HookView({ + canEditHooks, + hook, + id, + inspectPath, + path, +}: HookViewProps) { + const { name, id: hookID, isStateEditable, subHooks, value } = hook; + + const bridge = useContext(BridgeContext); + const store = useContext(StoreContext); + + const [isOpen, setIsOpen] = useState(false); + + const toggleIsOpen = useCallback( + () => setIsOpen(prevIsOpen => !prevIsOpen), + [] + ); + + if (hook.hasOwnProperty(meta.inspected)) { + // This Hook is too deep and hasn't been hydrated. + if (__DEV__) { + console.warn('Unexpected dehydrated hook; this is a DevTools error.'); + } + return ( +
+
+ ... +
+
+ ); + } + + const isCustomHook = subHooks.length > 0; + + const type = typeof value; + + let displayValue; + let isComplexDisplayValue = false; + + // Format data for display to mimic the props/state/context for now. + if (type === 'string') { + displayValue = `"${((value: any): string)}"`; + } else if (type === 'boolean') { + displayValue = value ? 'true' : 'false'; + } else if (type === 'number') { + displayValue = value; + } else if (value === null) { + displayValue = 'null'; + } else if (value === undefined) { + displayValue = null; + } else if (Array.isArray(value)) { + isComplexDisplayValue = true; + displayValue = 'Array'; + } else if (type === 'object') { + isComplexDisplayValue = true; + displayValue = 'Object'; + } + + if (isCustomHook) { + const subHooksView = Array.isArray(subHooks) ? ( + + ) : ( + + ); + + if (isComplexDisplayValue) { + return ( +
+
+ + + {name || 'Anonymous'} + +
+ +
+ ); + } else { + return ( +
+
+ + + {name || 'Anonymous'} + {' '} + {/* $FlowFixMe */} + {displayValue} +
+ +
+ ); + } + } else { + let overrideValueFn = null; + // TODO Maybe read editable value from debug hook? + if (canEditHooks && isStateEditable) { + overrideValueFn = (absolutePath: Array, value: any) => { + const rendererID = store.getRendererIDForElement(id); + if (rendererID !== null) { + bridge.send('overrideHookState', { + id, + hookID, + // Hooks override function expects a relative path for the specified hook (id), + // starting with its id within the (flat) hooks list structure. + // This relative path does not include the fake tree structure DevTools uses for display, + // so it's important that we remove that part of the path before sending the update. + path: absolutePath.slice(path.length + 1), + rendererID, + value, + }); + } + }; + } + + if (isComplexDisplayValue) { + return ( +
+ +
+ ); + } else { + return ( +
+
+ + + {name} + + {typeof overrideValueFn === 'function' ? ( + + ) : ( + // $FlowFixMe Cannot create span element because in property children + {displayValue} + )} +
+
+ ); + } + } +} + +// $FlowFixMe +export default React.memo(HooksTreeView); diff --git a/extension/src/devtools/views/Components/InspectHostNodesToggle.js b/extension/src/devtools/views/Components/InspectHostNodesToggle.js new file mode 100644 index 0000000000000..06fad24c10977 --- /dev/null +++ b/extension/src/devtools/views/Components/InspectHostNodesToggle.js @@ -0,0 +1,41 @@ +// @flow + +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { BridgeContext } from '../context'; +import Toggle from '../Toggle'; +import ButtonIcon from '../ButtonIcon'; + +export default function InspectHostNodesToggle() { + const [isInspecting, setIsInspecting] = useState(false); + const bridge = useContext(BridgeContext); + + const handleChange = useCallback( + (isChecked: boolean) => { + setIsInspecting(isChecked); + + if (isChecked) { + bridge.send('startInspectingNative'); + } else { + bridge.send('stopInspectingNative', false); + } + }, + [bridge] + ); + + useEffect(() => { + const onStopInspectingNative = () => setIsInspecting(false); + bridge.addListener('stopInspectingNative', onStopInspectingNative); + return () => + bridge.removeListener('stopInspectingNative', onStopInspectingNative); + }, [bridge]); + + return ( + + + + ); +} diff --git a/extension/src/devtools/views/Components/InspectedElementContext.js b/extension/src/devtools/views/Components/InspectedElementContext.js new file mode 100644 index 0000000000000..cc9d1f5969f33 --- /dev/null +++ b/extension/src/devtools/views/Components/InspectedElementContext.js @@ -0,0 +1,309 @@ +// @flow + +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { unstable_batchedUpdates as batchedUpdates } from 'react-dom'; +import { createResource } from '../../cache'; +import { BridgeContext, StoreContext } from '../context'; +import { hydrate, fillInPath } from 'src/hydration'; +import { TreeStateContext } from './TreeContext'; +import { separateDisplayNameAndHOCs } from 'src/utils'; + +import type { + InspectedElement as InspectedElementBackend, + InspectedElementPayload, +} from 'src/backend/types'; +import type { + DehydratedData, + Element, + InspectedElement as InspectedElementFrontend, +} from 'src/devtools/views/Components/types'; +import type { Resource, Thenable } from '../../cache'; + +export type GetInspectedElementPath = ( + id: number, + path: Array +) => void; +export type GetInspectedElement = ( + id: number +) => InspectedElementFrontend | null; + +type Context = {| + getInspectedElementPath: GetInspectedElementPath, + getInspectedElement: GetInspectedElement, +|}; + +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) => { + let 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 } +); + +type Props = {| + children: React$Node, +|}; + +function InspectedElementContextController({ children }: Props) { + const bridge = useContext(BridgeContext); + const store = useContext(StoreContext); + + // 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 [ + 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; + + let element; + + 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 }; + + fillInPath(inspectedElement, data.path, value); + + resource.write(element, inspectedElement); + + // Schedule update with React if the curently-selected element has been invalidated. + if (id === selectedElementID) { + setCurrentlyInspectedElement(inspectedElement); + } + } + } + break; + case 'full-data': + const { + canEditFunctionProps, + canEditHooks, + canToggleSuspense, + canViewSource, + source, + type, + owners, + context, + hooks, + props, + state, + } = ((data.value: any): InspectedElementBackend); + + const inspectedElement: InspectedElementFrontend = { + canEditFunctionProps, + canEditHooks, + canToggleSuspense, + canViewSource, + id, + 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), + }; + + 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); + + // Schedule update with React if the curently-selected element has been invalidated. + if (id === selectedElementID) { + setCurrentlyInspectedElement(inspectedElement); + } + } + } + break; + default: + break; + } + }; + + bridge.addListener('inspectedElement', onInspectedElement); + return () => bridge.removeListener('inspectedElement', onInspectedElement); + }, [bridge, currentlyInspectedElement, selectedElementID, store]); + + // This effect handler polls for updates on the currently selected element. + useEffect(() => { + if (selectedElementID === null) { + return () => {}; + } + + const rendererID = store.getRendererIDForElement(selectedElementID); + + let timeoutID: TimeoutID | null = null; + + 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) { + clearTimeout(timeoutID); + } + }; + }, [bridge, selectedElementID, store]); + + const value = useMemo( + () => ({ getInspectedElement, getInspectedElementPath }), + // InspectedElement is used to invalidate the cache and schedule an update with React. + // eslint-disable-next-line react-hooks/exhaustive-deps + [currentlyInspectedElement, getInspectedElement, getInspectedElementPath] + ); + + return ( + + {children} + + ); +} + +function hydrateHelper( + dehydratedData: DehydratedData | null, + path?: Array +): Object | null { + if (dehydratedData !== null) { + let { cleaned, data } = 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. + cleaned = cleaned.map(cleanedPath => cleanedPath.slice(length)); + } + } + + return hydrate(data, cleaned); + } else { + return null; + } +} + +export { InspectedElementContext, InspectedElementContextController }; diff --git a/extension/src/devtools/views/Components/InspectedElementTree.css b/extension/src/devtools/views/Components/InspectedElementTree.css new file mode 100644 index 0000000000000..620fbf412dd04 --- /dev/null +++ b/extension/src/devtools/views/Components/InspectedElementTree.css @@ -0,0 +1,48 @@ +.InspectedElementTree { + padding: 0.25rem; + border-top: 1px solid var(--color-border); +} +.InspectedElementTree:first-of-type { + border-top: none; +} + +.HeaderRow { + display: flex; + align-items: center; +} + +.Header { + flex: 1 1; + font-family: var(--font-family-sans); +} + +.Item { + display: flex; +} + +.Name { + color: var(--color-attribute-name); + flex: 0 0 auto; +} +.Name:after { + content: ': '; + color: var(--color-text); + margin-right: 0.5rem; +} + +.Value { + color: var(--color-attribute-value); + overflow: hidden; + text-overflow: ellipsis; +} + +.None { + color: var(--color-dimmer); + font-style: italic; +} + +.Empty { + color: var(--color-dimmer); + font-style: italic; + padding-left: 0.75rem; +} diff --git a/extension/src/devtools/views/Components/InspectedElementTree.js b/extension/src/devtools/views/Components/InspectedElementTree.js new file mode 100644 index 0000000000000..5475b236c8999 --- /dev/null +++ b/extension/src/devtools/views/Components/InspectedElementTree.js @@ -0,0 +1,65 @@ +// @flow + +import { copy } from 'clipboard-js'; +import React, { useCallback } from 'react'; +import Button from '../Button'; +import ButtonIcon from '../ButtonIcon'; +import KeyValue from './KeyValue'; +import { serializeDataForCopy } from '../utils'; +import styles from './InspectedElementTree.css'; + +import type { InspectPath } from './SelectedElement'; + +type OverrideValueFn = (path: Array, value: any) => void; + +type Props = {| + data: Object | null, + inspectPath?: InspectPath, + label: string, + overrideValueFn?: ?OverrideValueFn, + showWhenEmpty?: boolean, +|}; + +export default function InspectedElementTree({ + data, + inspectPath, + label, + overrideValueFn, + showWhenEmpty = false, +}: Props) { + const isEmpty = data === null || Object.keys(data).length === 0; + + const handleCopy = useCallback(() => copy(serializeDataForCopy(data)), [ + data, + ]); + + if (isEmpty && !showWhenEmpty) { + return null; + } else { + return ( +
+
+
{label}
+ {!isEmpty && ( + + )} +
+ {isEmpty &&
None
} + {!isEmpty && + Object.keys((data: any)).map(name => ( + + ))} +
+ ); + } +} diff --git a/extension/src/devtools/views/Components/KeyValue.css b/extension/src/devtools/views/Components/KeyValue.css new file mode 100644 index 0000000000000..5ccbeb1e669bc --- /dev/null +++ b/extension/src/devtools/views/Components/KeyValue.css @@ -0,0 +1,37 @@ +.Item:not([hidden]) { + display: flex; +} + +.Name { + color: var(--color-dim); + flex: 0 0 auto; + user-select: none; +} +.EditableName { + color: var(--color-attribute-name); + flex: 0 0 auto; + user-select: none; +} +.EditableName:after, +.Name:after { + content: ': '; + color: var(--color-text); + margin-right: 0.5rem; +} + +.Value { + color: var(--color-attribute-value); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.None { + color: var(--color-dimmer); + font-style: italic; +} + +.ExpandCollapseToggleSpacer { + flex: 0 0 1rem; + width: 1rem; +} diff --git a/extension/src/devtools/views/Components/KeyValue.js b/extension/src/devtools/views/Components/KeyValue.js new file mode 100644 index 0000000000000..7fd333a0a8ff9 --- /dev/null +++ b/extension/src/devtools/views/Components/KeyValue.js @@ -0,0 +1,194 @@ +// @flow + +import React, { useEffect, useRef, useState } from 'react'; +import type { Element } from 'react'; +import EditableValue from './EditableValue'; +import ExpandCollapseToggle from './ExpandCollapseToggle'; +import { getMetaValueLabel } from '../utils'; +import { meta } from '../../../hydration'; +import styles from './KeyValue.css'; + +import type { InspectPath } from './SelectedElement'; + +type OverrideValueFn = (path: Array, value: any) => void; + +type KeyValueProps = {| + depth: number, + hidden?: boolean, + inspectPath?: InspectPath, + name: string, + overrideValueFn?: ?OverrideValueFn, + path: Array, + value: any, +|}; + +export default function KeyValue({ + depth, + inspectPath, + hidden, + name, + overrideValueFn, + path, + value, +}: KeyValueProps) { + const [isOpen, setIsOpen] = useState(false); + const prevIsOpenRef = useRef(isOpen); + + const isInspectable = + value !== null && + typeof value === 'object' && + value[meta.inspectable] && + value[meta.size] !== 0; + + useEffect(() => { + if ( + isInspectable && + isOpen && + !prevIsOpenRef.current && + typeof inspectPath === 'function' + ) { + inspectPath(path); + } + prevIsOpenRef.current = isOpen; + }, [inspectPath, isInspectable, isOpen, path]); + + const toggleIsOpen = () => setIsOpen(prevIsOpen => !prevIsOpen); + + const dataType = typeof value; + const isSimpleType = + dataType === 'number' || + dataType === 'string' || + dataType === 'boolean' || + value == null; + + const style = { + paddingLeft: `${(depth - 1) * 0.75}rem`, + }; + + let children = null; + if (isSimpleType) { + let displayValue = value; + if (dataType === 'string') { + displayValue = `"${value}"`; + } else if (dataType === 'boolean') { + displayValue = value ? 'true' : 'false'; + } else if (value === null) { + displayValue = 'null'; + } else if (value === undefined) { + displayValue = 'undefined'; + } + + const nameClassName = + typeof overrideValueFn === 'function' ? styles.EditableName : styles.Name; + + children = ( +