diff --git a/.eslintignore b/.eslintignore index 60fbacc575..4b9cfb7dd7 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,6 +6,7 @@ shells/browser/firefox/build shells/browser/shared/build shells/dev/dist vendor +*.js.snap package-lock.json -yarn.lock \ No newline at end of file +yarn.lock diff --git a/.eslintrc b/.eslintrc index 5065c9aae8..5ee6406982 100644 --- a/.eslintrc +++ b/.eslintrc @@ -12,6 +12,7 @@ }, "globals": { "__DEV__": "readonly", - "jasmine": "readonly" + "jasmine": "readonly", + "spyOn": "readonly" } } diff --git a/OVERVIEW.md b/OVERVIEW.md index a2d8a67fa9..eba370d98b 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -14,9 +14,9 @@ The old DevTools also rendered the entire application tree in the form of a larg 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. The rest of the array depends on the operations being made to the tree. +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 many 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. +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 @@ -211,13 +211,39 @@ while (index !== currentWeight) { } ``` +## 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. + +### 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 and console logs will be invoked during this additional (shallow) render. + +### 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. -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.) +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.) 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. @@ -226,161 +252,16 @@ When profiling begins, the backend records the base durations of each fiber curr * Which elements were rendered during that commit. * Which interactions (if any) were part of the commit. -This information is kept on the backend until requested by the frontend (as described below). +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). 1 In the future, the backend may also store additional metadata (e.g. which props/states changed between rendered for a given component). -### Profiling summary +### Combining profiling data -The profiling tab shows information for the currently-selected React root. When profiling completes (or when a new root is selected) the frontend first checks to see if there is any profiling data for the selected root. (Has it cached any "_operations_"?) +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. -If so, then it sends a "_getProfilingSummary_" message with an id that identifies the root. The backend then returns a "_profilingSummary_" message with the following information: +### Importing/exporting data -* root id (to match request and response) -* number of interactions that were traced for this root -* the commits (each consisting of a timestamp and duration) that were profiled for the root -* tree base durations as of when profiling started +Because all of the data is merged in the frontend after a profiling session is completed, it can be exported and imported (as JSON), enabling profiling sessions to be shared between users. -This is the minimal information required to render the main ["commit selector"](https://reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html#browsing-commits). - -Here is an example profile summary: -```js -{ - rootID: 1, - interactionCount: 2, - - // Commit durations - commitDurations: [ - 10, // first commit took 10ms - 13, // second commit took 13ms - 5, // third commit took 5ms - ] - - // Commit times (relative to when profiling started) - commitTimes: [ - 210, // first commit started 210ms after profiling began - 284, // second commit started 284ms after profiling began - 303, // third commit started 303ms after profiling began - ], - - // Tuples of fiber id and initial tree base duration - initialTreeBaseDurations: [ - 1, // fiber id - 11, // tree base duration when profiling started - - 2, // fiber id - 12, // tree base duration when profiling started - - 3, // fiber id - 8, // tree base duration when profiling started - ] -] -``` - -Additional information (e.g. which components were part of a specific commit, which interactions were logged) are lazily requested by the frontend as a user interacts with the Profiler UI. - -### Commit details - -When a commit is selected in the profiling view, the frontend needs to reconstruct the tree at that point in time using the snapshot and the "_operations_" it has cached. - -In addition to this, it also needs to ask the backend for some additional information needed to display the ["flame chart"](https://reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html#flame-chart) and ["ranked chart"](https://reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html#ranked-chart) views. The frontend sends a "_profileCommitDetails_" message specifying which root and commit (index) it is interested in. The backend sends a response to fill in missing details about the commit: - -* root id and commit index (to match request and response) -* which elements were rendered during the commit 1 and how long did they take -* which interactions were part of the commit - -Here is an example commit in which two elements were rendered and one interaction was traced: - -```js -{ - rootID: 1, - commitIndex: 0, - interactions: [ - { - id: 8, - timestamp: 4, - name: "Foo" - }, - { - id: 11, - timestamp: 4, - name: "Bar" - } - ], - nodes: [ - { - id: 1, - baseDuration: 15, - actualDuration: 15 - }, - { - id: 2, - baseDuration: 11, - actualDuration: 11 - } - } -} -``` - -1 Elements in the tree that are not explicitly included in the above response were not rendered during the current commit. - -### Component commits - -When a particular component (fiber) is selected, the frontend polls the backend for the aggregate data required to render the ["component chart"](https://reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html#component-chart) view. The frontend sends a "_profileComponentDetails_" message specifying which root and component (id) it is interested in. The backend sends a response that includes: - -* root and component ids (to match request and response) -* which commits was the component rendered in and how long did each take - -Here is an example of a component that committed twice during a profiling session: - -```js -{ - rootID: 1, - id: 2, - - // Tuples of commit index and render duration (ms) - commits: [ - 0, // index of first - 11 // duration (ms) - - 2, // index of second commit - 7 // duration (ms) - ] -} -``` - -### Interactions - -The [Interactions chart](https://reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html#interactions) shows a time series for every interaction that was traced in the recent profiler session. The frontend sends a "_profileInteractions_" message specifying which root it would like interaction data for. The backend sends the following response: - -* root id (to match request and response) -* interaction metadata - -Here is an example of a profiling session consisting of two interactions: - -```js -{ - rootID: 1, - interactions: [ - { - id: 8, - name: "Foo", - timestamp: 4, - commits: [ - 0, // index of first commit - 2 // index of second commit - ] - }, - { - id: 11, - name: "Bar", - timestamp: 4, - commits: [ - 0 // index of first commit - ] - } - ] -} -``` - -The backend does not need to resend the timestamp for each of the commits because that was already sent as part of the "_profilingSummary_" message. +At the moment, screenshots are not included in the exported data (to keep the export filesize small) but this could be changed in the future. \ No newline at end of file diff --git a/package.json b/package.json index 3958ceb4bd..8dcd8bd6fe 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "/src/__tests__/setupTests" ], "snapshotSerializers": [ + "/src/__tests__/inspectedElementSerializer", "/src/__tests__/storeSerializer" ], "testMatch": [ @@ -78,7 +79,7 @@ "@babel/preset-react": "^7.0.0", "@reach/menu-button": "^0.1.17", "@reach/tooltip": "^0.2.0", - "adm-zip": "^0.4.7", + "archiver": "^3.0.0", "babel-core": "^7.0.0-bridge", "babel-eslint": "^9.0.0", "babel-jest": "^24.7.1", @@ -89,7 +90,6 @@ "cli-spinners": "^1.0.0", "clipboard-js": "^0.3.6", "cross-env": "^5.2.0", - "crx": "git+https://github.com/oncletom/crx#ef150e8", "css-loader": "^1.0.1", "error-stack-parser": "^2.0.2", "es6-symbol": "3.0.2", @@ -131,21 +131,21 @@ "opener": "^1.5.1", "prettier": "^1.16.4", "prop-types": "^15.6.2", - "react": "^0.0.0-6da04b5d8", + "react": "^0.0.0-50b50c26f", "react-color": "^2.11.7", - "react-dom": "^0.0.0-6da04b5d8", - "react-is": "^0.0.0-6da04b5d8", - "react-test-renderer": "^0.0.0-6da04b5d8", + "react-dom": "^0.0.0-50b50c26f", + "react-is": "^0.0.0-50b50c26f", + "react-test-renderer": "^0.0.0-50b50c26f", "react-virtualized-auto-sizer": "^1.0.2", "react-window": "^1.8.0", "request-promise": "^4.2.4", - "scheduler": "^0.0.0-6da04b5d8", + "rimraf": "^2.6.3", + "scheduler": "^0.0.0-50b50c26f", "semver": "^5.5.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", - "xml-js": "^1.6.11" + "webpack-dev-server": "^3.3.1" } } diff --git a/shells/browser/chrome/build.js b/shells/browser/chrome/build.js index 0f5905e2de..57b2dbb1f9 100644 --- a/shells/browser/chrome/build.js +++ b/shells/browser/chrome/build.js @@ -1,35 +1,10 @@ #!/usr/bin/env node const chalk = require('chalk'); -const { execSync } = require('child_process'); -const { join } = require('path'); -const rp = require('request-promise'); -const convert = require('xml-js'); const build = require('../shared/build'); const main = async () => { - const manifestVersion = await rp( - 'https://react-devtools-experimental-chrome.now.sh/updates.xml' - ) - .then(xmlString => { - const parsedXML = convert.xml2js(xmlString); - const version = - parsedXML.elements[0].elements[0].elements[0].attributes.version; - const match = /(\d)\.(\d)\.(\d)\.*(\d)*/.exec(version); - if (match !== null) { - const prerelease = parseInt(match[4], 10) || 0; - return `${match[1]}.${match[2]}.${match[3]}.${prerelease + 1}`; - } - }) - .catch(error => null); - - await build('chrome', manifestVersion); - - const cwd = join(__dirname, 'build'); - execSync('crx pack ./unpacked -o ReactDevTools.crx -p ../../../../key.pem', { - cwd, - }); - execSync('rm packed.zip', { cwd }); + await build('chrome'); console.log(chalk.green('\nThe Chrome extension has been built!')); console.log(chalk.green('You can test this build by running:')); diff --git a/shells/browser/chrome/manifest.json b/shells/browser/chrome/manifest.json index d6ee523d48..c24fe09e86 100644 --- a/shells/browser/chrome/manifest.json +++ b/shells/browser/chrome/manifest.json @@ -4,8 +4,6 @@ "description": "Adds React debugging tools to the Chrome Developer Tools.", "version": "4.0.0", - "update_url": "https://react-devtools-experimental-chrome.now.sh/updates.xml", - "minimum_chrome_version": "49", "icons": { @@ -44,8 +42,8 @@ "permissions": [ "", "background", - "downloads", "tabs", + "webNavigation", "file:///*", "http://*/*", "https://*/*" diff --git a/shells/browser/chrome/now.json b/shells/browser/chrome/now.json index fa36a08c68..e541ec866d 100644 --- a/shells/browser/chrome/now.json +++ b/shells/browser/chrome/now.json @@ -1,5 +1,5 @@ { "name": "react-devtools-experimental-chrome", "alias": ["react-devtools-experimental-chrome"], - "files": ["index.html", "updates.xml", "ReactDevTools.crx"] + "files": ["index.html", "ReactDevTools.zip"] } diff --git a/shells/browser/chrome/updates.xml b/shells/browser/chrome/updates.xml deleted file mode 100644 index 460aa13403..0000000000 --- a/shells/browser/chrome/updates.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/shells/browser/firefox/manifest.json b/shells/browser/firefox/manifest.json index 3ef00fba01..02f24a0fef 100644 --- a/shells/browser/firefox/manifest.json +++ b/shells/browser/firefox/manifest.json @@ -46,9 +46,9 @@ "permissions": [ "", - "downloads", "activeTab", "tabs", + "webNavigation", "file:///*", "http://*/*", "https://*/*" diff --git a/shells/browser/firefox/now.json b/shells/browser/firefox/now.json index 324d62388e..5e61bb442f 100644 --- a/shells/browser/firefox/now.json +++ b/shells/browser/firefox/now.json @@ -1,5 +1,5 @@ { "name": "react-devtools-experimental-firefox", "alias": ["react-devtools-experimental-firefox"], - "files": ["index.html", "packed.zip"] + "files": ["index.html", "ReactDevTools.zip"] } diff --git a/shells/browser/shared/build.js b/shells/browser/shared/build.js index fb1037f7a8..8cc9de1c3a 100644 --- a/shells/browser/shared/build.js +++ b/shells/browser/shared/build.js @@ -1,8 +1,8 @@ #!/usr/bin/env node -const AdmZip = require('adm-zip'); +const archiver = require('archiver'); const { execSync } = require('child_process'); -const { readFileSync, writeFileSync } = require('fs'); +const { readFileSync, writeFileSync, createWriteStream } = require('fs'); const { copy, ensureDir, move, remove } = require('fs-extra'); const { join } = require('path'); const { getGitCommit } = require('../../utils'); @@ -17,7 +17,7 @@ const preProcess = async (destinationPath, tempPath) => { await ensureDir(tempPath); // Create temp dir for this new build }; -const build = async (tempPath, manifestPath, manifestVersion) => { +const build = async (tempPath, manifestPath) => { const binPath = join(tempPath, 'bin'); const zipPath = join(tempPath, 'zip'); @@ -62,23 +62,27 @@ const build = async (tempPath, manifestPath, manifestVersion) => { const commit = getGitCommit(); const manifest = JSON.parse(readFileSync(copiedManifestPath).toString()); - manifest.description += `\n\nCreated from revision ${commit}`; - if (manifestVersion) { - manifest.version = manifestVersion; - manifest.version_name = `${manifestVersion} ${commit}`; - } + manifest.description += `\n\nCreated from revision ${commit} (${new Date().toLocaleDateString()})`; + manifest.version_name = `${commit} (${new Date().toLocaleDateString()})`; writeFileSync(copiedManifestPath, JSON.stringify(manifest, null, 2)); // Pack the extension - const zip = new AdmZip(); - zip.addLocalFolder(zipPath); - zip.writeZip(join(tempPath, 'packed.zip')); + 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, 'packed.zip'); - const packedDestPath = join(destinationPath, 'packed.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 @@ -86,7 +90,7 @@ const postProcess = async (tempPath, destinationPath) => { await remove(tempPath); // Clean up temp directory and files }; -const main = async (buildId, manifestVersion) => { +const main = async buildId => { const root = join(__dirname, '..', buildId); const manifestPath = join(root, 'manifest.json'); const destinationPath = join(root, 'build'); @@ -94,7 +98,7 @@ const main = async (buildId, manifestVersion) => { try { const tempPath = join(__dirname, 'build', buildId); await preProcess(destinationPath, tempPath); - await build(tempPath, manifestPath, manifestVersion); + await build(tempPath, manifestPath); const builtUnpackedPath = join(destinationPath, 'unpacked'); await postProcess(tempPath, destinationPath); diff --git a/shells/browser/shared/deploy.chrome.html b/shells/browser/shared/deploy.chrome.html index ccef0c2724..eb70be0024 100644 --- a/shells/browser/shared/deploy.chrome.html +++ b/shells/browser/shared/deploy.chrome.html @@ -1,7 +1,8 @@
    -
  1. download extension
  2. -
  3. Navigate to chrome://extensions/
  4. +
  5. download extension
  6. +
  7. Double-click to extract
  8. +
  9. Navigate to chrome://extensions/
  10. Enable "Developer mode"
  11. -
  12. Drag ReactDevTools.crx into Chrome
  13. -
  14. Choose "Add Extension" when prompted
  15. +
  16. Click "LOAD UNPACKED"
  17. +
  18. Select extracted extension folder (ReactDevTools)
\ No newline at end of file diff --git a/shells/browser/shared/deploy.firefox.html b/shells/browser/shared/deploy.firefox.html index 57051bf586..d2879a17c4 100644 --- a/shells/browser/shared/deploy.firefox.html +++ b/shells/browser/shared/deploy.firefox.html @@ -1,7 +1,7 @@
    -
  1. download extension
  2. +
  3. download extension
  4. Extract/unzip
  5. Visit about:debugging
  6. Click "Load Temporary Add-on"
  7. -
  8. Select the manifest.json
  9. +
  10. Select the manifest.json file inside of the extracted extension folder (ReactDevTools)
\ No newline at end of file diff --git a/shells/browser/shared/deploy.js b/shells/browser/shared/deploy.js index 349c6c8106..6cc33e1cbf 100644 --- a/shells/browser/shared/deploy.js +++ b/shells/browser/shared/deploy.js @@ -3,7 +3,6 @@ const { exec, execSync } = require('child_process'); const { readFileSync, writeFileSync } = require('fs'); const { join } = require('path'); -const convert = require('xml-js'); const main = async buildId => { const root = join(__dirname, '..', buildId); @@ -22,18 +21,6 @@ const main = async buildId => { cwd: root, }); - if (buildId === 'chrome') { - const file = readFileSync(join(buildPath, 'unpacked', 'manifest.json')); - const json = JSON.parse(file); - const { version } = json; - - const xmlString = readFileSync(join(root, 'updates.xml'), 'utf8'); - const parsedXML = convert.xml2js(xmlString); - parsedXML.elements[0].elements[0].elements[0].attributes.version = version; - - writeFileSync(join(buildPath, 'updates.xml'), convert.js2xml(parsedXML)); - } - const file = readFileSync(join(root, 'now.json')); const json = JSON.parse(file); const alias = json.alias[0]; diff --git a/shells/browser/shared/src/background.js b/shells/browser/shared/src/background.js index 64e0bfead8..1090ef76d8 100644 --- a/shells/browser/shared/src/background.js +++ b/shells/browser/shared/src/background.js @@ -110,17 +110,6 @@ chrome.runtime.onMessage.addListener((request, sender) => { setIconAndPopup(reactBuildType, sender.tab.id); } - if (request.exportFile) { - let { contents, filename } = request; - if (!Array.isArray(contents)) { - contents = [contents]; - } - - const blob = new Blob(contents, { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - chrome.downloads.download({ filename, saveAs: true, url }); - } - if (request.captureScreenshot) { const { commitIndex, rootID } = request; try { diff --git a/shells/browser/shared/src/main.js b/shells/browser/shared/src/main.js index d2da26ba2b..bf194d3c5e 100644 --- a/shells/browser/shared/src/main.js +++ b/shells/browser/shared/src/main.js @@ -51,6 +51,8 @@ function createPanelIfReactLoaded() { let bridge = null; let store = null; + let profilingData = null; + let componentsPortalContainer = null; let profilerPortalContainer = null; let settingsPortalContainer = null; @@ -85,13 +87,6 @@ function createPanelIfReactLoaded() { localStorage.setItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY, 'true'); chrome.devtools.inspectedWindow.eval('window.location.reload();'); }); - bridge.addListener('exportFile', ({ contents, filename }) => { - chrome.runtime.sendMessage({ - exportFile: true, - contents, - filename, - }); - }); bridge.addListener('captureScreenshot', ({ commitIndex, rootID }) => { chrome.runtime.sendMessage( { @@ -124,10 +119,10 @@ function createPanelIfReactLoaded() { store = new Store(bridge, { isProfiling, supportsCaptureScreenshots: true, - supportsFileDownloads: browserName === 'Chrome', supportsReloadAndProfile: true, 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. @@ -286,18 +281,29 @@ function createPanelIfReactLoaded() { chrome.devtools.network.onNavigated.removeListener(checkPageForReact); - // Shutdown bridge and re-initialize DevTools panel when a new page is loaded. + // Shutdown bridge before a new page is loaded. + chrome.webNavigation.onBeforeNavigate.addListener( + function onBeforeNavigate(details) { + // `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. initializeSavedComponentFilters(); - // `bridge.shutdown()` will remove all listeners we added, so we don't have to. - bridge.shutdown(); - // 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)); + flushSync(() => { + root.unmount(() => { + initBridgeAndStore(); + }); + }); }); } ); diff --git a/shells/dev/app/ElementTypes/index.js b/shells/dev/app/ElementTypes/index.js index fa027f5926..6ef562596c 100644 --- a/shells/dev/app/ElementTypes/index.js +++ b/shells/dev/app/ElementTypes/index.js @@ -6,8 +6,6 @@ import React, { lazy, memo, Component, - // $FlowFixMe Flow thinks ConcurrentMode is stable - unstable_ConcurrentMode as ConcurrentMode, Fragment, // $FlowFixMe Flow doesn't know about the Profiler import yet Profiler, @@ -48,15 +46,13 @@ export default function ElementTypes() { {value => null} - - Loading...}> - - - - - - - + Loading...}> + + + + + + diff --git a/shells/dev/app/PriorityLevels/index.js b/shells/dev/app/PriorityLevels/index.js new file mode 100644 index 0000000000..2888451980 --- /dev/null +++ b/shells/dev/app/PriorityLevels/index.js @@ -0,0 +1,46 @@ +// @flow + +import React, { Fragment, useCallback, useState } from 'react'; +import { + unstable_IdlePriority as IdlePriority, + unstable_LowPriority as LowPriority, + unstable_runWithPriority as runWithPriority, +} from 'scheduler'; + +export default function PriorityLevels() { + const [defaultPriority, setDefaultPriority] = useState(false); + const [idlePriority, setIdlePriority] = useState(false); + const [normalPriority, setLowPriority] = useState(false); + + const resetSequence = useCallback(() => { + setDefaultPriority(false); + setLowPriority(false); + setIdlePriority(false); + }, []); + + const startSequence = useCallback(() => { + setDefaultPriority(true); + runWithPriority(LowPriority, () => setLowPriority(true)); + runWithPriority(IdlePriority, () => setIdlePriority(true)); + }, []); + + const labels = []; + if (defaultPriority) { + labels.push('(default priority)'); + } + if (normalPriority) { + labels.push('Low Priority'); + } + if (idlePriority) { + labels.push('Idle Priority'); + } + + return ( + +

Priority Levels

+ + + {labels.join(', ')} +
+ ); +} diff --git a/shells/dev/app/index.js b/shells/dev/app/index.js index 3a76db783f..1a2336982c 100644 --- a/shells/dev/app/index.js +++ b/shells/dev/app/index.js @@ -3,28 +3,33 @@ // This test harness mounts each test app as a separate root to test multi-root applications. import { createElement } from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; +import { + // $FlowFixMe Flow does not yet know about createRoot() + unstable_createRoot as createRoot, +} from 'react-dom'; import DeeplyNestedComponents from './DeeplyNestedComponents'; import EditableProps from './EditableProps'; import ElementTypes from './ElementTypes'; import InspectableElements from './InspectableElements'; import InteractionTracing from './InteractionTracing'; +import PriorityLevels from './PriorityLevels'; import ToDoList from './ToDoList'; import Toggle from './Toggle'; import SuspenseTree from './SuspenseTree'; import './styles.css'; -const containers = []; +const roots = []; function mountHelper(App) { const container = document.createElement('div'); ((document.body: any): HTMLBodyElement).appendChild(container); - containers.push(container); + const root = createRoot(container); + root.render(createElement(App)); - render(createElement(App), container); + roots.push(root); } function mountTestApp() { @@ -33,13 +38,14 @@ function mountTestApp() { mountHelper(InspectableElements); mountHelper(ElementTypes); mountHelper(EditableProps); + mountHelper(PriorityLevels); mountHelper(Toggle); mountHelper(SuspenseTree); mountHelper(DeeplyNestedComponents); } function unmountTestApp() { - containers.forEach(container => unmountComponentAtNode(container)); + roots.forEach(root => root.unmount()); } mountTestApp(); diff --git a/shells/utils.js b/shells/utils.js index 6f55ca8158..515f5c242b 100644 --- a/shells/utils.js +++ b/shells/utils.js @@ -9,13 +9,19 @@ function getGitCommit() { } function getGitHubURL() { - // TODO potentially replac this with an fb.me URL (if it can forward the query params) - return execSync('git remote get-url origin') + // 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() - .replace(':', '/') - .replace('git@', 'https://') - .replace('.git', ''); + .trim(); + + if (url.startsWith('https://')) { + return url.replace('.git', ''); + } else { + return url + .replace(':', '/') + .replace('git@', 'https://') + .replace('.git', ''); + } } function getVersionString() { diff --git a/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap b/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap new file mode 100644 index 0000000000..6f353a16ee --- /dev/null +++ b/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap @@ -0,0 +1,116 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InspectedElementContext should inspect the currently selected element: 1: mount 1`] = ` +[root] + +`; + +exports[`InspectedElementContext should inspect the currently selected element: 2: Inspected element 2 1`] = ` +{ + "id": 2, + "owners": null, + "context": null, + "events": null, + "hooks": [ + { + "id": 0, + "isStateEditable": true, + "name": "State", + "value": 1, + "subHooks": [] + } + ], + "props": { + "foo": 1, + "bar": "abc" + }, + "state": null +} +`; + +exports[`InspectedElementContext should not re-render a function with hooks if it did not update since it was last inspected: 1: mount 1`] = ` +[root] + ▾ + +`; + +exports[`InspectedElementContext should not re-render a function with hooks if it did not update since it was last inspected: 2: initial render 1`] = ` +{ + "id": 3, + "owners": null, + "context": null, + "events": null, + "hooks": [ + { + "id": 0, + "isStateEditable": true, + "name": "State", + "value": 0, + "subHooks": [] + } + ], + "props": { + "foo": 1, + "bar": "abc" + }, + "state": null +} +`; + +exports[`InspectedElementContext should not re-render a function with hooks if it did not update since it was last inspected: 3: updated state 1`] = ` +{ + "id": 3, + "owners": null, + "context": null, + "events": null, + "hooks": [ + { + "id": 0, + "isStateEditable": true, + "name": "State", + "value": 0, + "subHooks": [] + } + ], + "props": { + "foo": 2, + "bar": "def" + }, + "state": null +} +`; + +exports[`InspectedElementContext should poll for updates for the currently selected element: 1: mount 1`] = ` +[root] + +`; + +exports[`InspectedElementContext should poll for updates for the currently selected element: 2: initial render 1`] = ` +{ + "id": 2, + "owners": null, + "context": null, + "events": null, + "hooks": null, + "props": { + "foo": 1, + "bar": "abc" + }, + "state": null +} +`; + +exports[`InspectedElementContext should poll for updates for the currently selected element: 2: updated state 1`] = ` +{ + "id": 2, + "owners": null, + "context": null, + "events": null, + "hooks": null, + "props": { + "foo": 2, + "bar": "def" + }, + "state": null +} +`; diff --git a/src/__tests__/__snapshots__/ownersListContext-test.js.snap b/src/__tests__/__snapshots__/ownersListContext-test.js.snap new file mode 100644 index 0000000000..0f1e213a05 --- /dev/null +++ b/src/__tests__/__snapshots__/ownersListContext-test.js.snap @@ -0,0 +1,77 @@ +// 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", + "id": 7, + }, + Object { + "displayName": "Parent", + "id": 9, + }, + Object { + "displayName": "Child", + "id": 8, + }, +] +`; + +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", + "id": 2, + }, + Object { + "displayName": "Parent", + "id": 3, + }, + Object { + "displayName": "Child", + "id": 4, + }, +] +`; + +exports[`OwnersListContext should fetch the owners list for the selected element: owners for "Parent" 1`] = ` +Array [ + Object { + "displayName": "Grandparent", + "id": 2, + }, + Object { + "displayName": "Parent", + "id": 3, + }, +] +`; + +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", + "id": 5, + }, +] +`; diff --git a/src/__tests__/__snapshots__/profilerContext-test.js.snap b/src/__tests__/__snapshots__/profilerContext-test.js.snap new file mode 100644 index 0000000000..927f3180b4 --- /dev/null +++ b/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/src/__tests__/__snapshots__/profiling-test.js.snap b/src/__tests__/__snapshots__/profiling-test.js.snap deleted file mode 100644 index fa3916d352..0000000000 --- a/src/__tests__/__snapshots__/profiling-test.js.snap +++ /dev/null @@ -1,1017 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`profiling CommitDetails should be collected for each commit: CommitDetails commitIndex: 0 1`] = ` -Object { - "actualDurations": Map { - 1 => 12, - 2 => 12, - 3 => 0, - 4 => 1, - 5 => 1, - }, - "commitIndex": 0, - "interactions": Array [], - "rootID": 1, -} -`; - -exports[`profiling CommitDetails should be collected for each commit: CommitDetails commitIndex: 1 1`] = ` -Object { - "actualDurations": Map { - 3 => 0, - 4 => 1, - 6 => 2, - 2 => 13, - 1 => 13, - }, - "commitIndex": 1, - "interactions": Array [], - "rootID": 1, -} -`; - -exports[`profiling CommitDetails should be collected for each commit: CommitDetails commitIndex: 2 1`] = ` -Object { - "actualDurations": Map { - 3 => 0, - 2 => 10, - 1 => 10, - }, - "commitIndex": 2, - "interactions": Array [], - "rootID": 1, -} -`; - -exports[`profiling CommitDetails should be collected for each commit: CommitDetails commitIndex: 3 1`] = ` -Object { - "actualDurations": Map { - 2 => 10, - 1 => 10, - }, - "commitIndex": 3, - "interactions": Array [], - "rootID": 1, -} -`; - -exports[`profiling CommitDetails should be collected for each commit: exported data 1`] = ` -Object { - "commitDetails": Array [ - Object { - "actualDurations": Array [ - 1, - 12, - 2, - 12, - 3, - 0, - 4, - 1, - 5, - 1, - ], - "commitIndex": 0, - "interactions": Array [], - "rootID": 1, - }, - Object { - "actualDurations": Array [ - 3, - 0, - 4, - 1, - 6, - 2, - 2, - 13, - 1, - 13, - ], - "commitIndex": 1, - "interactions": Array [], - "rootID": 1, - }, - Object { - "actualDurations": Array [ - 3, - 0, - 2, - 10, - 1, - 10, - ], - "commitIndex": 2, - "interactions": Array [], - "rootID": 1, - }, - Object { - "actualDurations": Array [ - 2, - 10, - 1, - 10, - ], - "commitIndex": 3, - "interactions": Array [], - "rootID": 1, - }, - ], - "interactions": Object { - "interactions": Array [], - "rootID": 1, - }, - "profilingOperations": Map { - 1 => Array [ - Array [ - 1, - 1, - 29, - 6, - 80, - 97, - 114, - 101, - 110, - 116, - 5, - 67, - 104, - 105, - 108, - 100, - 1, - 48, - 1, - 49, - 11, - 77, - 101, - 109, - 111, - 40, - 67, - 104, - 105, - 108, - 100, - 41, - 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, - 5, - 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, - ], - ], - }, - "profilingSnapshots": Map { - 1 => Map {}, - }, - "profilingSummary": Object { - "commitDurations": Array [ - 12, - 13, - 10, - 10, - ], - "commitTimes": Array [ - 12, - 25, - 35, - 45, - ], - "initialTreeBaseDurations": Array [], - "interactionCount": 0, - "rootID": 1, - }, - "version": 2, -} -`; - -exports[`profiling FiberCommits should be collected for each rendered fiber: FiberCommits: element 2 1`] = ` -Object { - "commitDurations": Array [ - 0, - 11, - 1, - 11, - 2, - 13, - ], - "fiberID": 2, - "rootID": 1, -} -`; - -exports[`profiling FiberCommits should be collected for each rendered fiber: FiberCommits: element 3 1`] = ` -Object { - "commitDurations": Array [ - 0, - 0, - 1, - 0, - 2, - 0, - ], - "fiberID": 3, - "rootID": 1, -} -`; - -exports[`profiling FiberCommits should be collected for each rendered fiber: FiberCommits: element 4 1`] = ` -Object { - "commitDurations": Array [ - 0, - 1, - ], - "fiberID": 4, - "rootID": 1, -} -`; - -exports[`profiling FiberCommits should be collected for each rendered fiber: FiberCommits: element 5 1`] = ` -Object { - "commitDurations": Array [ - 1, - 1, - 2, - 1, - ], - "fiberID": 5, - "rootID": 1, -} -`; - -exports[`profiling FiberCommits should be collected for each rendered fiber: FiberCommits: element 6 1`] = ` -Object { - "commitDurations": Array [ - 2, - 2, - ], - "fiberID": 6, - "rootID": 1, -} -`; - -exports[`profiling FiberCommits should be collected for each rendered fiber: exported data 1`] = ` -Object { - "commitDetails": Array [ - Object { - "actualDurations": Array [ - 1, - 11, - 2, - 11, - 3, - 0, - 4, - 1, - ], - "commitIndex": 0, - "interactions": Array [], - "rootID": 1, - }, - Object { - "actualDurations": Array [ - 3, - 0, - 5, - 1, - 2, - 11, - 1, - 11, - ], - "commitIndex": 1, - "interactions": Array [], - "rootID": 1, - }, - Object { - "actualDurations": Array [ - 3, - 0, - 5, - 1, - 6, - 2, - 2, - 13, - 1, - 13, - ], - "commitIndex": 2, - "interactions": Array [], - "rootID": 1, - }, - ], - "interactions": Object { - "interactions": Array [], - "rootID": 1, - }, - "profilingOperations": Map { - 1 => Array [ - Array [ - 1, - 1, - 27, - 6, - 80, - 97, - 114, - 101, - 110, - 116, - 5, - 67, - 104, - 105, - 108, - 100, - 1, - 48, - 11, - 77, - 101, - 109, - 111, - 40, - 67, - 104, - 105, - 108, - 100, - 41, - 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, - 4, - 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, - ], - ], - }, - "profilingSnapshots": Map { - 1 => Map {}, - }, - "profilingSummary": Object { - "commitDurations": Array [ - 11, - 11, - 13, - ], - "commitTimes": Array [ - 11, - 22, - 35, - ], - "initialTreeBaseDurations": Array [], - "interactionCount": 0, - "rootID": 1, - }, - "version": 2, -} -`; - -exports[`profiling Interactions should be collected for every traced interaction: Interactions 1`] = ` -Array [ - Object { - "__count": 1, - "commits": Array [ - 0, - ], - "id": 0, - "name": "mount: one child", - "timestamp": 0, - }, - Object { - "__count": 0, - "commits": Array [ - 1, - ], - "id": 1, - "name": "update: two children", - "timestamp": 11, - }, -] -`; - -exports[`profiling Interactions should be collected for every traced interaction: exported data 1`] = ` -Object { - "commitDetails": Array [ - Object { - "actualDurations": Array [ - 1, - 11, - 2, - 11, - 3, - 0, - 4, - 1, - ], - "commitIndex": 0, - "interactions": Array [ - Object { - "__count": 1, - "id": 0, - "name": "mount: one child", - "timestamp": 0, - }, - ], - "rootID": 1, - }, - Object { - "actualDurations": Array [ - 3, - 0, - 5, - 1, - 2, - 11, - 1, - 11, - ], - "commitIndex": 1, - "interactions": Array [ - Object { - "__count": 0, - "id": 1, - "name": "update: two children", - "timestamp": 11, - }, - ], - "rootID": 1, - }, - ], - "interactions": Object { - "interactions": Array [ - Object { - "__count": 1, - "commits": Array [ - 0, - ], - "id": 0, - "name": "mount: one child", - "timestamp": 0, - }, - Object { - "__count": 0, - "commits": Array [ - 1, - ], - "id": 1, - "name": "update: two children", - "timestamp": 11, - }, - ], - "rootID": 1, - }, - "profilingOperations": Map { - 1 => Array [ - Array [ - 1, - 1, - 27, - 6, - 80, - 97, - 114, - 101, - 110, - 116, - 5, - 67, - 104, - 105, - 108, - 100, - 1, - 48, - 11, - 77, - 101, - 109, - 111, - 40, - 67, - 104, - 105, - 108, - 100, - 41, - 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, - 4, - 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, - ], - ], - }, - "profilingSnapshots": Map { - 1 => Map {}, - }, - "profilingSummary": Object { - "commitDurations": Array [ - 11, - 11, - ], - "commitTimes": Array [ - 11, - 22, - ], - "initialTreeBaseDurations": Array [], - "interactionCount": 2, - "rootID": 1, - }, - "version": 2, -} -`; - -exports[`profiling ProfilingSummary should be collected for each commit: ProfilingSummary 1`] = ` -Object { - "commitDurations": Array [ - 13, - 10, - 10, - ], - "commitTimes": Array [ - 13, - 23, - 33, - ], - "initialTreeBaseDurations": Map { - 1 => 12, - 2 => 12, - 3 => 0, - 4 => 1, - 5 => 1, - }, - "interactionCount": 0, - "rootID": 1, -} -`; - -exports[`profiling ProfilingSummary should be collected for each commit: exported data 1`] = ` -Object { - "commitDetails": Array [ - Object { - "actualDurations": Array [ - 3, - 0, - 4, - 1, - 6, - 2, - 2, - 13, - 1, - 13, - ], - "commitIndex": 0, - "interactions": Array [], - "rootID": 1, - }, - Object { - "actualDurations": Array [ - 3, - 0, - 2, - 10, - 1, - 10, - ], - "commitIndex": 1, - "interactions": Array [], - "rootID": 1, - }, - Object { - "actualDurations": Array [ - 2, - 10, - 1, - 10, - ], - "commitIndex": 2, - "interactions": Array [], - "rootID": 1, - }, - ], - "interactions": Object { - "interactions": Array [], - "rootID": 1, - }, - "profilingOperations": Map { - 1 => Array [ - 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, - ], - ], - }, - "profilingSnapshots": Map { - 1 => Map { - 1 => Object { - "children": Array [ - 2, - ], - "displayName": null, - "id": 1, - "key": null, - }, - 2 => Object { - "children": Array [ - 3, - 4, - 5, - ], - "displayName": "Parent", - "id": 2, - "key": null, - }, - 3 => Object { - "children": Array [], - "displayName": "Child", - "id": 3, - "key": "0", - }, - 4 => Object { - "children": Array [], - "displayName": "Child", - "id": 4, - "key": "1", - }, - 5 => Object { - "children": Array [], - "displayName": "Memo(Child)", - "id": 5, - "key": null, - }, - }, - }, - "profilingSummary": Object { - "commitDurations": Array [ - 13, - 10, - 10, - ], - "commitTimes": Array [ - 13, - 23, - 33, - ], - "initialTreeBaseDurations": Array [ - 1, - 12, - 2, - 12, - 3, - 0, - 4, - 1, - 5, - 1, - ], - "interactionCount": 0, - "rootID": 1, - }, - "version": 2, -} -`; diff --git a/src/__tests__/__snapshots__/profilingCache-test.js.snap b/src/__tests__/__snapshots__/profilingCache-test.js.snap new file mode 100644 index 0000000000..f9901da82a --- /dev/null +++ b/src/__tests__/__snapshots__/profilingCache-test.js.snap @@ -0,0 +1,1869 @@ +// 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 { + "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", + "screenshot": null, + "timestamp": 16, +} +`; + +exports[`ProfilingCache should calculate self duration correctly for suspended views: CommitDetails with filtered self durations 1`] = ` +Object { + "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", + "screenshot": null, + "timestamp": 15, +} +`; + +exports[`ProfilingCache should calculate self duration correctly for suspended views: CommitDetails with filtered self durations 2`] = ` +Object { + "duration": 3, + "fiberActualDurations": Map { + 5 => 3, + 3 => 3, + }, + "fiberSelfDurations": Map { + 5 => 3, + 3 => 0, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "screenshot": null, + "timestamp": 18, +} +`; + +exports[`ProfilingCache should collect data for each commit: CommitDetails commitIndex: 0 1`] = ` +Object { + "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", + "screenshot": null, + "timestamp": 12, +} +`; + +exports[`ProfilingCache should collect data for each commit: CommitDetails commitIndex: 1 1`] = ` +Object { + "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", + "screenshot": null, + "timestamp": 25, +} +`; + +exports[`ProfilingCache should collect data for each commit: CommitDetails commitIndex: 2 1`] = ` +Object { + "duration": 10, + "fiberActualDurations": Map { + 3 => 0, + 2 => 10, + 1 => 10, + }, + "fiberSelfDurations": Map { + 3 => 0, + 2 => 10, + 1 => 0, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "screenshot": null, + "timestamp": 35, +} +`; + +exports[`ProfilingCache should collect data for each commit: CommitDetails commitIndex: 3 1`] = ` +Object { + "duration": 10, + "fiberActualDurations": Map { + 2 => 10, + 1 => 10, + }, + "fiberSelfDurations": Map { + 2 => 10, + 1 => 0, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "screenshot": null, + "timestamp": 45, +} +`; + +exports[`ProfilingCache should collect data for each commit: imported data 1`] = ` +Object { + "dataForRoots": Array [ + Object { + "commitData": Array [ + Object { + "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", + "screenshot": null, + "timestamp": 12, + }, + Object { + "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", + "screenshot": null, + "timestamp": 25, + }, + Object { + "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", + "screenshot": null, + "timestamp": 35, + }, + Object { + "duration": 10, + "fiberActualDurations": Array [ + Array [ + 2, + 10, + ], + Array [ + 1, + 10, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 2, + 10, + ], + Array [ + 1, + 0, + ], + ], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "screenshot": null, + "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 { + "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", + "screenshot": null, + "timestamp": 11, + }, + Object { + "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", + "screenshot": null, + "timestamp": 22, + }, + Object { + "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", + "screenshot": null, + "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 { + "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", + "screenshot": null, + "timestamp": 13, + }, + Object { + "duration": 10, + "fiberActualDurations": Map { + 3 => 0, + 2 => 10, + 1 => 10, + }, + "fiberSelfDurations": Map { + 3 => 0, + 2 => 10, + 1 => 0, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "screenshot": null, + "timestamp": 34, + }, + Object { + "duration": 10, + "fiberActualDurations": Map { + 2 => 10, + 1 => 10, + }, + "fiberSelfDurations": Map { + 2 => 10, + 1 => 0, + }, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "screenshot": null, + "timestamp": 44, + }, + ], + "displayName": "Parent", + "initialTreeBaseDurations": Map { + 1 => 12, + 2 => 12, + 3 => 0, + 4 => 1, + 5 => 1, + }, + "interactionCommits": Map {}, + "interactions": Map {}, + "operations": Array [ + Uint32Array [ + 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, + ], + Uint32Array [ + 1, + 1, + 0, + 2, + 2, + 10, + 4, + 4, + 2, + 11000, + 3, + 2, + 2, + 3, + 5, + 4, + 1, + 11000, + ], + Uint32Array [ + 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 { + "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", + "screenshot": null, + "timestamp": 24, + }, + ], + "displayName": "Parent", + "initialTreeBaseDurations": Map {}, + "interactionCommits": Map {}, + "interactions": Map {}, + "operations": Array [ + Uint32Array [ + 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 { + "duration": 0, + "fiberActualDurations": Map {}, + "fiberSelfDurations": Map {}, + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "screenshot": null, + "timestamp": 34, + }, + ], + "displayName": "Parent", + "initialTreeBaseDurations": Map { + 6 => 11, + 7 => 11, + 8 => 0, + 9 => 1, + }, + "interactionCommits": Map {}, + "interactions": Map {}, + "operations": Array [ + Uint32Array [ + 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 { + "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", + "screenshot": null, + "timestamp": 13, + }, + Object { + "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", + "screenshot": null, + "timestamp": 34, + }, + Object { + "duration": 10, + "fiberActualDurations": Array [ + Array [ + 2, + 10, + ], + Array [ + 1, + 10, + ], + ], + "fiberSelfDurations": Array [ + Array [ + 2, + 10, + ], + Array [ + 1, + 0, + ], + ], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "screenshot": null, + "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 { + "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", + "screenshot": null, + "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 { + "duration": 0, + "fiberActualDurations": Array [], + "fiberSelfDurations": Array [], + "interactionIDs": Array [], + "priorityLevel": "Immediate", + "screenshot": null, + "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 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 { + "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", + "screenshot": null, + "timestamp": 11, + }, + Object { + "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", + "screenshot": null, + "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/src/__tests__/__snapshots__/profilingCharts-test.js.snap b/src/__tests__/__snapshots__/profilingCharts-test.js.snap index feb708bac7..8399be81c6 100644 --- a/src/__tests__/__snapshots__/profilingCharts-test.js.snap +++ b/src/__tests__/__snapshots__/profilingCharts-test.js.snap @@ -12,6 +12,7 @@ Object { "key": null, "parentID": 0, "treeBaseDuration": 15, + "type": 11, }, 2 => Object { "children": Array [ @@ -24,30 +25,34 @@ Object { "key": null, "parentID": 1, "treeBaseDuration": 15, + "type": 5, }, 3 => Object { "children": Array [], - "displayName": "Memo(Child)", + "displayName": "Child", "id": 3, "key": "first", "parentID": 2, "treeBaseDuration": 3, + "type": 8, }, 4 => Object { "children": Array [], - "displayName": "Memo(Child)", + "displayName": "Child", "id": 4, "key": "second", "parentID": 2, "treeBaseDuration": 2, + "type": 8, }, 5 => Object { "children": Array [], - "displayName": "Memo(Child)", + "displayName": "Child", "id": 5, "key": "third", "parentID": 2, "treeBaseDuration": 0, + "type": 8, }, }, "rootID": 1, @@ -65,6 +70,10 @@ Object { 3 => 2, }, "maxSelfDuration": 10, + "renderPathNodes": Set { + 1, + 2, + }, "rows": Array [ Array [ Object { @@ -83,8 +92,8 @@ Object { "actualDuration": 0, "didRender": true, "id": 5, - "label": "Memo(Child) key=\\"third\\" (<0.1ms of <0.1ms)", - "name": "Memo(Child)", + "label": "Child (Memo) key=\\"third\\" (<0.1ms of <0.1ms)", + "name": "Child", "offset": 15, "selfDuration": 0, "treeBaseDuration": 0, @@ -93,8 +102,8 @@ Object { "actualDuration": 2, "didRender": true, "id": 4, - "label": "Memo(Child) key=\\"second\\" (2ms of 2ms)", - "name": "Memo(Child)", + "label": "Child (Memo) key=\\"second\\" (2ms of 2ms)", + "name": "Child", "offset": 13, "selfDuration": 2, "treeBaseDuration": 2, @@ -103,8 +112,8 @@ Object { "actualDuration": 3, "didRender": true, "id": 3, - "label": "Memo(Child) key=\\"first\\" (3ms of 3ms)", - "name": "Memo(Child)", + "label": "Child (Memo) key=\\"first\\" (3ms of 3ms)", + "name": "Child", "offset": 10, "selfDuration": 3, "treeBaseDuration": 3, @@ -126,6 +135,7 @@ Object { "key": null, "parentID": 0, "treeBaseDuration": 15, + "type": 11, }, 2 => Object { "children": Array [ @@ -138,30 +148,34 @@ Object { "key": null, "parentID": 1, "treeBaseDuration": 15, + "type": 5, }, 3 => Object { "children": Array [], - "displayName": "Memo(Child)", + "displayName": "Child", "id": 3, "key": "first", "parentID": 2, "treeBaseDuration": 3, + "type": 8, }, 4 => Object { "children": Array [], - "displayName": "Memo(Child)", + "displayName": "Child", "id": 4, "key": "second", "parentID": 2, "treeBaseDuration": 2, + "type": 8, }, 5 => Object { "children": Array [], - "displayName": "Memo(Child)", + "displayName": "Child", "id": 5, "key": "third", "parentID": 2, "treeBaseDuration": 0, + "type": 8, }, }, "rootID": 1, @@ -179,6 +193,9 @@ Object { 3 => 2, }, "maxSelfDuration": 10, + "renderPathNodes": Set { + 1, + }, "rows": Array [ Array [ Object { @@ -197,8 +214,8 @@ Object { "actualDuration": 0, "didRender": false, "id": 5, - "label": "Memo(Child) key=\\"third\\"", - "name": "Memo(Child)", + "label": "Child (Memo) key=\\"third\\"", + "name": "Child", "offset": 15, "selfDuration": 0, "treeBaseDuration": 0, @@ -207,8 +224,8 @@ Object { "actualDuration": 0, "didRender": false, "id": 4, - "label": "Memo(Child) key=\\"second\\"", - "name": "Memo(Child)", + "label": "Child (Memo) key=\\"second\\"", + "name": "Child", "offset": 13, "selfDuration": 0, "treeBaseDuration": 2, @@ -217,8 +234,8 @@ Object { "actualDuration": 0, "didRender": false, "id": 3, - "label": "Memo(Child) key=\\"first\\"", - "name": "Memo(Child)", + "label": "Child (Memo) key=\\"first\\"", + "name": "Child", "offset": 10, "selfDuration": 0, "treeBaseDuration": 3, @@ -230,6 +247,20 @@ Object { 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, } @@ -237,6 +268,20 @@ Object { 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, } @@ -254,6 +299,7 @@ Object { "key": null, "parentID": 0, "treeBaseDuration": 15, + "type": 11, }, 2 => Object { "children": Array [ @@ -266,30 +312,34 @@ Object { "key": null, "parentID": 1, "treeBaseDuration": 15, + "type": 5, }, 3 => Object { "children": Array [], - "displayName": "Memo(Child)", + "displayName": "Child", "id": 3, "key": "first", "parentID": 2, "treeBaseDuration": 3, + "type": 8, }, 4 => Object { "children": Array [], - "displayName": "Memo(Child)", + "displayName": "Child", "id": 4, "key": "second", "parentID": 2, "treeBaseDuration": 2, + "type": 8, }, 5 => Object { "children": Array [], - "displayName": "Memo(Child)", + "displayName": "Child", "id": 5, "key": "third", "parentID": 2, "treeBaseDuration": 0, + "type": 8, }, }, "rootID": 1, @@ -308,20 +358,20 @@ Object { }, Object { "id": 3, - "label": "Memo(Child) key=\\"first\\" (3ms)", - "name": "Memo(Child)", + "label": "Child (Memo) key=\\"first\\" (3ms)", + "name": "Child", "value": 3, }, Object { "id": 4, - "label": "Memo(Child) key=\\"second\\" (2ms)", - "name": "Memo(Child)", + "label": "Child (Memo) key=\\"second\\" (2ms)", + "name": "Child", "value": 2, }, Object { "id": 5, - "label": "Memo(Child) key=\\"third\\" (<0.1ms)", - "name": "Memo(Child)", + "label": "Child (Memo) key=\\"third\\" (<0.1ms)", + "name": "Child", "value": 0, }, ], @@ -340,6 +390,7 @@ Object { "key": null, "parentID": 0, "treeBaseDuration": 15, + "type": 11, }, 2 => Object { "children": Array [ @@ -352,30 +403,34 @@ Object { "key": null, "parentID": 1, "treeBaseDuration": 15, + "type": 5, }, 3 => Object { "children": Array [], - "displayName": "Memo(Child)", + "displayName": "Child", "id": 3, "key": "first", "parentID": 2, "treeBaseDuration": 3, + "type": 8, }, 4 => Object { "children": Array [], - "displayName": "Memo(Child)", + "displayName": "Child", "id": 4, "key": "second", "parentID": 2, "treeBaseDuration": 2, + "type": 8, }, 5 => Object { "children": Array [], - "displayName": "Memo(Child)", + "displayName": "Child", "id": 5, "key": "third", "parentID": 2, "treeBaseDuration": 0, + "type": 8, }, }, "rootID": 1, diff --git a/src/__tests__/__snapshots__/profilingCommitTreeBuilder-test.js.snap b/src/__tests__/__snapshots__/profilingCommitTreeBuilder-test.js.snap index b483cd5efc..6f8573b08e 100644 --- a/src/__tests__/__snapshots__/profilingCommitTreeBuilder-test.js.snap +++ b/src/__tests__/__snapshots__/profilingCommitTreeBuilder-test.js.snap @@ -12,6 +12,7 @@ Object { "key": null, "parentID": 0, "treeBaseDuration": 12, + "type": 11, }, 2 => Object { "children": Array [ @@ -22,14 +23,16 @@ Object { "key": null, "parentID": 1, "treeBaseDuration": 12, + "type": 5, }, 3 => Object { "children": Array [], - "displayName": "Memo(Child)", + "displayName": "Child", "id": 3, "key": "0", "parentID": 2, "treeBaseDuration": 2, + "type": 8, }, }, "rootID": 1, @@ -48,6 +51,7 @@ Object { "key": null, "parentID": 0, "treeBaseDuration": 16, + "type": 11, }, 2 => Object { "children": Array [ @@ -60,30 +64,34 @@ Object { "key": null, "parentID": 1, "treeBaseDuration": 16, + "type": 5, }, 3 => Object { "children": Array [], - "displayName": "Memo(Child)", + "displayName": "Child", "id": 3, "key": "0", "parentID": 2, "treeBaseDuration": 2, + "type": 8, }, 4 => Object { "children": Array [], - "displayName": "Memo(Child)", + "displayName": "Child", "id": 4, "key": "1", "parentID": 2, "treeBaseDuration": 2, + "type": 8, }, 5 => Object { "children": Array [], - "displayName": "Memo(Child)", + "displayName": "Child", "id": 5, "key": "2", "parentID": 2, "treeBaseDuration": 2, + "type": 8, }, }, "rootID": 1, @@ -102,6 +110,7 @@ Object { "key": null, "parentID": 0, "treeBaseDuration": 14, + "type": 11, }, 2 => Object { "children": Array [ @@ -113,22 +122,25 @@ Object { "key": null, "parentID": 1, "treeBaseDuration": 14, + "type": 5, }, 3 => Object { "children": Array [], - "displayName": "Memo(Child)", + "displayName": "Child", "id": 3, "key": "0", "parentID": 2, "treeBaseDuration": 2, + "type": 8, }, 4 => Object { "children": Array [], - "displayName": "Memo(Child)", + "displayName": "Child", "id": 4, "key": "1", "parentID": 2, "treeBaseDuration": 2, + "type": 8, }, }, "rootID": 1, @@ -147,6 +159,7 @@ Object { "key": null, "parentID": 0, "treeBaseDuration": 10, + "type": 11, }, 2 => Object { "children": Array [], @@ -155,6 +168,7 @@ Object { "key": null, "parentID": 1, "treeBaseDuration": 10, + "type": 5, }, }, "rootID": 1, diff --git a/src/__tests__/__snapshots__/treeContext-test.js.snap b/src/__tests__/__snapshots__/treeContext-test.js.snap new file mode 100644 index 0000000000..3a9149861b --- /dev/null +++ b/src/__tests__/__snapshots__/treeContext-test.js.snap @@ -0,0 +1,1023 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +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", + "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", + "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", + "id": 3, + "isCollapsed": false, + "key": null, + "ownerID": 2, + "parentID": 2, + "type": 5, + "weight": 3, + }, + Object { + "children": Array [], + "depth": 1, + "displayName": "Child", + "id": 4, + "isCollapsed": false, + "key": "0", + "ownerID": 3, + "parentID": 3, + "type": 5, + "weight": 1, + }, + Object { + "children": Array [], + "depth": 1, + "displayName": "Child", + "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", + "id": 3, + "isCollapsed": false, + "key": null, + "ownerID": 2, + "parentID": 2, + "type": 5, + "weight": 2, + }, + Object { + "children": Array [], + "depth": 1, + "displayName": "Child", + "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", + "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", + "id": 3, + "isCollapsed": false, + "key": null, + "ownerID": 2, + "parentID": 2, + "type": 5, + "weight": 3, + }, + Object { + "children": Array [], + "depth": 1, + "displayName": "Child", + "id": 4, + "isCollapsed": false, + "key": null, + "ownerID": 3, + "parentID": 3, + "type": 5, + "weight": 1, + }, + Object { + "children": Array [], + "depth": 1, + "displayName": "Child", + "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] + + + +`; + +exports[`TreeListContext search state should find elements matching search text: 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 find elements matching search text: 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 find elements matching search text: 3: search for "f" 1`] = ` +Object { + "inspectedElementID": 2, + "numElements": 3, + "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 "q" 1`] = ` +Object { + "inspectedElementID": 2, + "numElements": 3, + "ownerFlatTree": null, + "ownerID": null, + "searchIndex": null, + "searchResults": Array [], + "searchText": "q", + "selectedElementID": 2, + "selectedElementIndex": 0, +} +`; + +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/src/__tests__/bridge-test.js b/src/__tests__/bridge-test.js new file mode 100644 index 0000000000..1a9b4b949a --- /dev/null +++ b/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('init'); + jest.runAllTimers(); + expect(wall.send).toHaveBeenCalledWith('init', undefined, undefined); + + // 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', undefined); + expect(wall.send).toHaveBeenCalledWith('update', '2', undefined); + expect(wall.send).toHaveBeenCalledWith('shutdown', undefined, undefined); + + // 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/src/__tests__/inspectedElementContext-test.js b/src/__tests__/inspectedElementContext-test.js new file mode 100644 index 0000000000..2ae0b0743c --- /dev/null +++ b/src/__tests__/inspectedElementContext-test.js @@ -0,0 +1,252 @@ +// @flow + +import typeof ReactTestRenderer from 'react-test-renderer'; +import type { Element } from 'src/devtools/views/Components/types'; +import type Bridge from 'src/bridge'; +import type Store from 'src/devtools/store'; + +describe('InspectedElementContext', () => { + let React; + let ReactDOM; + let TestRenderer: ReactTestRenderer; + let bridge: Bridge; + let store: Store; + let utils; + + let BridgeContext; + let InspectedElementContext; + let InspectedElementContextController; + 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; + 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'); + utils.act(() => ReactDOM.render(, container)); + expect(store).toMatchSnapshot('1: mount'); + + const example = ((store.getElementAtIndex(0): any): Element); + + let didFinish = false; + + function Suspender({ target }) { + const { read } = React.useContext(InspectedElementContext); + const inspectedElement = read(target.id); + expect(inspectedElement).toMatchSnapshot( + `2: Inspected element ${target.id}` + ); + didFinish = true; + return null; + } + + await utils.actAsync( + () => + TestRenderer.create( + + + + + + ), + 3 + ); + 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'); + utils.act(() => ReactDOM.render(, container)); + expect(store).toMatchSnapshot('1: mount'); + + const example = ((store.getElementAtIndex(0): any): Element); + + let inspectedElement = null; + + function Suspender({ target }) { + const { read } = React.useContext(InspectedElementContext); + inspectedElement = read(target.id); + return null; + } + + await utils.actAsync( + () => + TestRenderer.create( + + + + + + ), + 3 + ); + expect(inspectedElement).toMatchSnapshot('2: initial render'); + + await utils.actAsync(() => + ReactDOM.render(, container) + ); + + inspectedElement = null; + await utils.actAsync( + () => + TestRenderer.create( + + + + + + ), + 1 + ); + 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'); + utils.act(() => + ReactDOM.render( + + + , + container + ) + ); + expect(store).toMatchSnapshot('1: mount'); + + const id = ((store.getElementIDAtIndex(1): any): number); + + let inspectedElement = null; + + function Suspender({ target }) { + const { read } = React.useContext(InspectedElementContext); + inspectedElement = read(target); + return null; + } + + targetRenderCount = 0; + + let renderer; + await utils.actAsync( + () => + (renderer = TestRenderer.create( + + + + + + )), + 3 + ); + expect(targetRenderCount).toBe(1); + expect(inspectedElement).toMatchSnapshot('2: initial render'); + + const initialInspectedElement = inspectedElement; + + targetRenderCount = 0; + inspectedElement = null; + await utils.actAsync( + () => + renderer.update( + + + + + + ), + 1 + ); + expect(targetRenderCount).toBe(0); + expect(inspectedElement).toEqual(initialInspectedElement); + + targetRenderCount = 0; + + await utils.actAsync(() => + ReactDOM.render( + + + , + container + ) + ); + + // Target should have been rendered once (by ReactDOM) and once by DevTools for inspection. + expect(targetRenderCount).toBe(2); + expect(inspectedElement).toMatchSnapshot('3: updated state'); + + done(); + }); +}); diff --git a/src/__tests__/inspectedElementSerializer.js b/src/__tests__/inspectedElementSerializer.js new file mode 100644 index 0000000000..13e105cf0d --- /dev/null +++ b/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/src/__tests__/ownersListContext-test.js b/src/__tests__/ownersListContext-test.js new file mode 100644 index 0000000000..428b9815cb --- /dev/null +++ b/src/__tests__/ownersListContext-test.js @@ -0,0 +1,208 @@ +// @flow + +import typeof ReactTestRenderer from 'react-test-renderer'; +import type { Element } from 'src/devtools/views/Components/types'; +import type Bridge from 'src/bridge'; +import type Store from 'src/devtools/store'; + +describe('OwnersListContext', () => { + let React; + let ReactDOM; + let TestRenderer: ReactTestRenderer; + let bridge: Bridge; + 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( + + + + + + ), + 3 + ); + expect(didFinish).toBe(true); + + didFinish = false; + await utils.actAsync( + () => + TestRenderer.create( + + + + + + ), + 3 + ); + 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( + + + + + + ), + 3 + ); + 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( + + + + + + ), + 3 + ); + expect(didFinish).toBe(true); + + done(); + }); +}); diff --git a/src/__tests__/profilerContext-test.js b/src/__tests__/profilerContext-test.js new file mode 100644 index 0000000000..3d3a68f591 --- /dev/null +++ b/src/__tests__/profilerContext-test.js @@ -0,0 +1,347 @@ +// @flow + +import typeof ReactTestRenderer from 'react-test-renderer'; +import type Bridge 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: Bridge; + 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; + + 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/src/__tests__/profilerStore-test.js b/src/__tests__/profilerStore-test.js new file mode 100644 index 0000000000..f6ffcfe783 --- /dev/null +++ b/src/__tests__/profilerStore-test.js @@ -0,0 +1,74 @@ +// @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; + + 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/src/__tests__/profiling-test.js b/src/__tests__/profiling-test.js deleted file mode 100644 index 8631c9c08a..0000000000 --- a/src/__tests__/profiling-test.js +++ /dev/null @@ -1,479 +0,0 @@ -// @flow - -import typeof ReactTestRenderer from 'react-test-renderer'; -import type Bridge from 'src/bridge'; -import type Store from 'src/devtools/store'; - -describe('profiling', () => { - let React; - let ReactDOM; - let Scheduler; - let SchedulerTracing; - let TestRenderer: ReactTestRenderer; - let bridge: Bridge; - let store: Store; - let utils; - - const exportImportHelper = (rendererID: number, rootID: number) => { - const { - prepareProfilingExport, - prepareProfilingImport, - } = require('src/devtools/views/Profiler/utils'); - - let exportedProfilingSummary; - bridge.addListener('exportFile', ({ contents }) => { - exportedProfilingSummary = contents; - }); - - utils.act(() => { - const exportProfilingSummary = prepareProfilingExport( - store.profilingOperations, - store.profilingSnapshots, - rootID, - rendererID - ); - bridge.send('exportProfilingSummary', exportProfilingSummary); - }); - - expect(exportedProfilingSummary).toBeDefined(); - - const importedProfilingSummary = prepareProfilingImport( - ((exportedProfilingSummary: any): string) - ); - - // Sanity check that profiling snapshots are serialized correctly. - expect(store.profilingSnapshots.get(rootID)).toEqual( - importedProfilingSummary.profilingSnapshots.get(rootID) - ); - - // Snapshot the JSON-parsed object, rather than the raw string, because Jest formats the diff nicer. - expect(importedProfilingSummary).toMatchSnapshot('exported data'); - - utils.act(() => { - store.importedProfilingData = importedProfilingSummary; - }); - }; - - beforeEach(() => { - utils = require('./utils'); - utils.beforeEachProfiling(); - - bridge = global.bridge; - store = global.store; - store.collapseNodesByDefault = false; - - React = require('react'); - ReactDOM = require('react-dom'); - Scheduler = require('scheduler'); - SchedulerTracing = require('scheduler/tracing'); - TestRenderer = utils.requireTestRenderer(); - }); - - it('should throw if importing older/unsupported data', () => { - const { - prepareProfilingImport, - } = require('src/devtools/views/Profiler/utils'); - expect(() => - prepareProfilingImport( - JSON.stringify({ - version: 0, - }) - ) - ).toThrow('Unsupported profiler export version "0"'); - }); - - describe('ProfilingSummary', () => { - it('should be collected for each commit', async done => { - const Parent = ({ count }) => { - Scheduler.advanceTime(10); - const children = new Array(count) - .fill(true) - .map((_, index) => ); - return ( - - {children} - - - ); - }; - const Child = ({ duration }) => { - Scheduler.advanceTime(duration); - return null; - }; - const MemoizedChild = React.memo(Child); - - const container = document.createElement('div'); - - utils.act(() => ReactDOM.render(, container)); - utils.act(() => store.startProfiling()); - utils.act(() => ReactDOM.render(, container)); - utils.act(() => ReactDOM.render(, container)); - utils.act(() => ReactDOM.render(, container)); - utils.act(() => store.stopProfiling()); - - let profilingSummary = null; - - function Suspender({ previousPofilingSummary, rendererID, rootID }) { - profilingSummary = store.profilingCache.ProfilingSummary.read({ - rendererID, - rootID, - }); - if (previousPofilingSummary != null) { - expect(profilingSummary).toEqual(previousPofilingSummary); - } else { - expect(profilingSummary).toMatchSnapshot('ProfilingSummary'); - } - return null; - } - - const rendererID = utils.getRendererID(); - const rootID = store.roots[0]; - - await utils.actSuspense(() => - TestRenderer.create( - - - - ) - ); - - expect(profilingSummary).not.toBeNull(); - - exportImportHelper(rendererID, rootID); - - await utils.actSuspense(() => - TestRenderer.create( - - - - ) - ); - - done(); - }); - }); - - describe('CommitDetails', () => { - it('should be collected for each commit', async done => { - const Parent = ({ count }) => { - Scheduler.advanceTime(10); - const children = new Array(count) - .fill(true) - .map((_, index) => ); - return ( - - {children} - - - ); - }; - const Child = ({ duration }) => { - Scheduler.advanceTime(duration); - return null; - }; - const MemoizedChild = React.memo(Child); - - const container = document.createElement('div'); - - utils.act(() => store.startProfiling()); - utils.act(() => ReactDOM.render(, container)); - utils.act(() => ReactDOM.render(, container)); - utils.act(() => ReactDOM.render(, container)); - utils.act(() => ReactDOM.render(, container)); - utils.act(() => store.stopProfiling()); - - const allCommitDetails = []; - - function Suspender({ - commitIndex, - previousCommitDetails, - rendererID, - rootID, - }) { - const commitDetails = store.profilingCache.CommitDetails.read({ - commitIndex, - rendererID, - rootID, - }); - if (previousCommitDetails != null) { - expect(commitDetails).toEqual(previousCommitDetails); - } else { - allCommitDetails.push(commitDetails); - expect(commitDetails).toMatchSnapshot( - `CommitDetails commitIndex: ${commitIndex}` - ); - } - return null; - } - - const rendererID = utils.getRendererID(); - const rootID = store.roots[0]; - - for (let commitIndex = 0; commitIndex < 4; commitIndex++) { - await utils.actSuspense(() => { - TestRenderer.create( - - - - ); - }); - } - - expect(allCommitDetails).toHaveLength(4); - - exportImportHelper(rendererID, rootID); - - for (let commitIndex = 0; commitIndex < 4; commitIndex++) { - await utils.actSuspense(() => { - TestRenderer.create( - - - - ); - }); - } - - done(); - }); - }); - - describe('FiberCommits', () => { - it('should be collected for each rendered fiber', async done => { - const Parent = ({ count }) => { - Scheduler.advanceTime(10); - const children = new Array(count) - .fill(true) - .map((_, index) => ); - return ( - - {children} - - - ); - }; - const Child = ({ duration }) => { - Scheduler.advanceTime(duration); - return null; - }; - const MemoizedChild = React.memo(Child); - - const container = document.createElement('div'); - - utils.act(() => store.startProfiling()); - utils.act(() => ReactDOM.render(, container)); - utils.act(() => ReactDOM.render(, container)); - utils.act(() => ReactDOM.render(, container)); - utils.act(() => store.stopProfiling()); - - const allFiberCommits = []; - - function Suspender({ - fiberID, - previousFiberCommits, - rendererID, - rootID, - }) { - const fiberCommits = store.profilingCache.FiberCommits.read({ - fiberID, - rendererID, - rootID, - }); - if (previousFiberCommits != null) { - expect(fiberCommits).toEqual(previousFiberCommits); - } else { - allFiberCommits.push(fiberCommits); - expect(fiberCommits).toMatchSnapshot( - `FiberCommits: element ${fiberID}` - ); - } - return null; - } - - const rendererID = utils.getRendererID(); - const rootID = store.roots[0]; - - for (let index = 0; index < store.numElements; index++) { - await utils.actSuspense(() => { - 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); - - exportImportHelper(rendererID, rootID); - - for (let index = 0; index < store.numElements; index++) { - await utils.actSuspense(() => { - const fiberID = store.getElementIDAtIndex(index); - if (fiberID == null) { - throw Error(`Unexpected null ID for element at index ${index}`); - } - TestRenderer.create( - - - - ); - }); - } - - done(); - }); - }); - - describe('Interactions', () => { - it('should be collected for every traced interaction', async done => { - const Parent = ({ count }) => { - Scheduler.advanceTime(10); - const children = new Array(count) - .fill(true) - .map((_, index) => ); - return ( - - {children} - - - ); - }; - const Child = ({ duration }) => { - Scheduler.advanceTime(duration); - return null; - }; - const MemoizedChild = React.memo(Child); - - const container = document.createElement('div'); - - utils.act(() => store.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.stopProfiling()); - - let interactions = null; - - function Suspender({ previousInteractions, rendererID, rootID }) { - interactions = store.profilingCache.Interactions.read({ - rendererID, - rootID, - }); - if (previousInteractions != null) { - expect(interactions).toEqual(previousInteractions); - } else { - expect(interactions).toMatchSnapshot('Interactions'); - } - return null; - } - - const rendererID = utils.getRendererID(); - const rootID = store.roots[0]; - - await utils.actSuspense(() => - TestRenderer.create( - - - - ) - ); - - expect(interactions).not.toBeNull(); - - exportImportHelper(rendererID, rootID); - - await utils.actSuspense(() => - TestRenderer.create( - - - - ) - ); - - done(); - }); - }); - - it('should 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.startProfiling()); - - utils.act(() => { - ReactDOM.render(, containerA); - ReactDOM.render(, containerB); - }); - - utils.act(() => ReactDOM.unmountComponentAtNode(containerB)); - - utils.act(() => ReactDOM.unmountComponentAtNode(containerA)); - - utils.act(() => store.stopProfiling()); - - // Assert all maps are empty - store.assertExpectedRootMapSizes(); - }); -}); diff --git a/src/__tests__/profilingCache-test.js b/src/__tests__/profilingCache-test.js new file mode 100644 index 0000000000..c93d347ea8 --- /dev/null +++ b/src/__tests__/profilingCache-test.js @@ -0,0 +1,455 @@ +// @flow + +import typeof ReactTestRenderer from 'react-test-renderer'; +import type Bridge from 'src/bridge'; +import type Store from 'src/devtools/store'; + +describe('ProfilingCache', () => { + let React; + let ReactDOM; + let Scheduler; + let SchedulerTracing; + let TestRenderer: ReactTestRenderer; + let bridge: Bridge; + let store: Store; + let utils; + + beforeEach(() => { + utils = require('./utils'); + utils.beforeEachProfiling(); + + bridge = global.bridge; + store = global.store; + store.collapseNodesByDefault = false; + + 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.advanceTime(10); + const children = new Array(count) + .fill(true) + .map((_, index) => ); + return ( + + {children} + + + ); + }; + const Child = ({ duration }) => { + Scheduler.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.advanceTime(10); + const children = new Array(count) + .fill(true) + .map((_, index) => ); + return ( + + {children} + + + ); + }; + const Child = ({ duration }) => { + Scheduler.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 calculate a self duration based on actual children (not filtered children)', () => { + store.componentFilters = [utils.createDisplayNameFilter('^Parent$')]; + + const Grandparent = () => { + Scheduler.advanceTime(10); + return ( + + + + + ); + }; + const Parent = () => { + Scheduler.advanceTime(2); + return ; + }; + const Child = () => { + Scheduler.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.advanceTime(10); + return ( + }> + + + ); + }; + const Fallback = () => { + Scheduler.advanceTime(2); + return 'Fallback...'; + }; + const Async = () => { + Scheduler.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.advanceTime(10); + const children = new Array(count) + .fill(true) + .map((_, index) => ); + return ( + + {children} + + + ); + }; + const Child = ({ duration }) => { + Scheduler.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.advanceTime(10); + const children = new Array(count) + .fill(true) + .map((_, index) => ); + return ( + + {children} + + + ); + }; + const Child = ({ duration }) => { + Scheduler.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/src/__tests__/profilingCharts-test.js b/src/__tests__/profilingCharts-test.js index abf77070dd..8e9248bb97 100644 --- a/src/__tests__/profilingCharts-test.js +++ b/src/__tests__/profilingCharts-test.js @@ -27,7 +27,7 @@ describe('profiling charts', () => { }); describe('flamegraph chart', () => { - it('should contain valid data', async done => { + it('should contain valid data', () => { const Parent = ({ count }) => { Scheduler.advanceTime(10); return ( @@ -47,7 +47,7 @@ describe('profiling charts', () => { const container = document.createElement('div'); - utils.act(() => store.startProfiling()); + utils.act(() => store.profilerStore.startProfiling()); utils.act(() => SchedulerTracing.unstable_trace('mount', Scheduler.unstable_now(), () => ReactDOM.render(, container) @@ -60,66 +60,49 @@ describe('profiling charts', () => { () => ReactDOM.render(, container) ) ); - utils.act(() => store.stopProfiling()); + utils.act(() => store.profilerStore.stopProfiling()); - let suspenseResolved = false; + let renderFinished = false; - function Suspender({ commitIndex, rendererID, rootID }) { - const profilingSummary = store.profilingCache.ProfilingSummary.read({ - rendererID, + function Validator({ commitIndex, rootID }) { + const commitTree = store.profilerStore.profilingCache.getCommitTree({ + commitIndex, rootID, }); - const commitDetails = store.profilingCache.CommitDetails.read({ - commitIndex, - rendererID, - rootID, - }); - suspenseResolved = true; - const commitTree = store.profilingCache.getCommitTree({ - commitIndex, - profilingSummary, - }); - const chartData = store.profilingCache.getFlamegraphChartData({ - commitDetails, - commitIndex, - commitTree, - }); + const chartData = store.profilerStore.profilingCache.getFlamegraphChartData( + { + commitIndex, + commitTree, + rootID, + } + ); expect(commitTree).toMatchSnapshot(`${commitIndex}: CommitTree`); expect(chartData).toMatchSnapshot( `${commitIndex}: FlamegraphChartData` ); + renderFinished = true; return null; } - const rendererID = utils.getRendererID(); const rootID = store.roots[0]; for (let commitIndex = 0; commitIndex < 2; commitIndex++) { - suspenseResolved = false; - - await utils.actSuspense(() => + renderFinished = false; + utils.act(() => { TestRenderer.create( - - - - ) - ); + + ); + }); - expect(suspenseResolved).toBe(true); + expect(renderFinished).toBe(true); } - expect(suspenseResolved).toBe(true); - - done(); + expect(renderFinished).toBe(true); }); }); describe('ranked chart', () => { - it('should contain valid data', async done => { + it('should contain valid data', () => { const Parent = ({ count }) => { Scheduler.advanceTime(10); return ( @@ -139,7 +122,7 @@ describe('profiling charts', () => { const container = document.createElement('div'); - utils.act(() => store.startProfiling()); + utils.act(() => store.profilerStore.startProfiling()); utils.act(() => SchedulerTracing.unstable_trace('mount', Scheduler.unstable_now(), () => ReactDOM.render(, container) @@ -152,62 +135,45 @@ describe('profiling charts', () => { () => ReactDOM.render(, container) ) ); - utils.act(() => store.stopProfiling()); + utils.act(() => store.profilerStore.stopProfiling()); - let suspenseResolved = false; + let renderFinished = false; - function Suspender({ commitIndex, rendererID, rootID }) { - const profilingSummary = store.profilingCache.ProfilingSummary.read({ - rendererID, + function Validator({ commitIndex, rootID }) { + const commitTree = store.profilerStore.profilingCache.getCommitTree({ + commitIndex, rootID, }); - const commitDetails = store.profilingCache.CommitDetails.read({ - commitIndex, - rendererID, - rootID, - }); - suspenseResolved = true; - const commitTree = store.profilingCache.getCommitTree({ - commitIndex, - profilingSummary, - }); - const chartData = store.profilingCache.getRankedChartData({ - commitDetails, - commitIndex, - commitTree, - }); + const chartData = store.profilerStore.profilingCache.getRankedChartData( + { + commitIndex, + commitTree, + rootID, + } + ); expect(commitTree).toMatchSnapshot(`${commitIndex}: CommitTree`); expect(chartData).toMatchSnapshot(`${commitIndex}: RankedChartData`); + renderFinished = true; return null; } - const rendererID = utils.getRendererID(); const rootID = store.roots[0]; for (let commitIndex = 0; commitIndex < 2; commitIndex++) { - suspenseResolved = false; - - await utils.actSuspense(() => + renderFinished = false; + utils.act(() => { TestRenderer.create( - - - - ) - ); + + ); + }); - expect(suspenseResolved).toBe(true); + expect(renderFinished).toBe(true); } - - done(); }); }); describe('interactions', () => { - it('should contain valid data', async done => { + it('should contain valid data', () => { const Parent = ({ count }) => { Scheduler.advanceTime(10); return ( @@ -227,7 +193,7 @@ describe('profiling charts', () => { const container = document.createElement('div'); - utils.act(() => store.startProfiling()); + utils.act(() => store.profilerStore.startProfiling()); utils.act(() => SchedulerTracing.unstable_trace('mount', Scheduler.unstable_now(), () => ReactDOM.render(, container) @@ -240,50 +206,33 @@ describe('profiling charts', () => { () => ReactDOM.render(, container) ) ); - utils.act(() => store.stopProfiling()); + utils.act(() => store.profilerStore.stopProfiling()); - let suspenseResolved = false; + let renderFinished = false; - function Suspender({ commitIndex, rendererID, rootID }) { - const profilingSummary = store.profilingCache.ProfilingSummary.read({ - rendererID, - rootID, - }); - const interactions = store.profilingCache.Interactions.read({ - rendererID, - rootID, - }); - suspenseResolved = true; - const chartData = store.profilingCache.getInteractionsChartData({ - interactions, - profilingSummary, - }); + function Validator({ commitIndex, rootID }) { + const chartData = store.profilerStore.profilingCache.getInteractionsChartData( + { + rootID, + } + ); expect(chartData).toMatchSnapshot('Interactions'); + renderFinished = true; return null; } - const rendererID = utils.getRendererID(); const rootID = store.roots[0]; for (let commitIndex = 0; commitIndex < 2; commitIndex++) { - suspenseResolved = false; - - await utils.actSuspense(() => + renderFinished = false; + utils.act(() => { TestRenderer.create( - - - - ) - ); + + ); + }); - expect(suspenseResolved).toBe(true); + expect(renderFinished).toBe(true); } - - done(); }); }); }); diff --git a/src/__tests__/profilingCommitTreeBuilder-test.js b/src/__tests__/profilingCommitTreeBuilder-test.js index cdc7777fef..e6c98c1d5e 100644 --- a/src/__tests__/profilingCommitTreeBuilder-test.js +++ b/src/__tests__/profilingCommitTreeBuilder-test.js @@ -24,7 +24,7 @@ describe('commit tree', () => { TestRenderer = utils.requireTestRenderer(); }); - it('should be able to rebuild the store tree for each commit', async done => { + it('should be able to rebuild the store tree for each commit', () => { const Parent = ({ count }) => { Scheduler.advanceTime(10); return new Array(count) @@ -38,50 +38,37 @@ describe('commit tree', () => { const container = document.createElement('div'); - utils.act(() => store.startProfiling()); + 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.stopProfiling()); + utils.act(() => store.profilerStore.stopProfiling()); - let suspenseResolved = false; + let renderFinished = false; - function Suspender({ commitIndex, rendererID, rootID }) { - const profilingSummary = store.profilingCache.ProfilingSummary.read({ - rendererID, + function Validator({ commitIndex, rootID }) { + const commitTree = store.profilerStore.profilingCache.getCommitTree({ + commitIndex, rootID, }); - suspenseResolved = true; - const commitTree = store.profilingCache.getCommitTree({ - commitIndex, - profilingSummary, - }); expect(commitTree).toMatchSnapshot(`${commitIndex}: CommitTree`); + renderFinished = true; return null; } - const rendererID = utils.getRendererID(); const rootID = store.roots[0]; for (let commitIndex = 0; commitIndex < 4; commitIndex++) { - suspenseResolved = false; + renderFinished = false; - await utils.actSuspense(() => + utils.act(() => { TestRenderer.create( - - - - ) - ); + + ); + }); - expect(suspenseResolved).toBe(true); + expect(renderFinished).toBe(true); } - - done(); }); }); diff --git a/src/__tests__/profilingUtils-test.js b/src/__tests__/profilingUtils-test.js new file mode 100644 index 0000000000..66029a2568 --- /dev/null +++ b/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/src/__tests__/setupTests.js b/src/__tests__/setupTests.js index 68916e13d3..2cd71ae731 100644 --- a/src/__tests__/setupTests.js +++ b/src/__tests__/setupTests.js @@ -53,9 +53,11 @@ env.beforeEach(() => { initBackend(hook, agent, global); + const store = new Store(bridge); + global.agent = agent; global.bridge = bridge; - global.store = new Store(bridge); + global.store = store; }); env.afterEach(() => { delete global.__REACT_DEVTOOLS_GLOBAL_HOOK__; diff --git a/src/__tests__/store-test.js b/src/__tests__/store-test.js index 38c5b33eab..7c273a7c31 100644 --- a/src/__tests__/store-test.js +++ b/src/__tests__/store-test.js @@ -778,4 +778,23 @@ describe('Store', () => { } }); }); + + 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/src/__tests__/storeComponentFilters-test.js b/src/__tests__/storeComponentFilters-test.js index 8f408f49f7..dff169e924 100644 --- a/src/__tests__/storeComponentFilters-test.js +++ b/src/__tests__/storeComponentFilters-test.js @@ -1,47 +1,14 @@ // @flow +import type Store from 'src/devtools/store'; + describe('Store component filters', () => { let React; let ReactDOM; let TestUtils; let Types; - let store; - - const createElementTypeFilter = (elementType, isEnabled = true) => ({ - type: Types.ComponentFilterElementType, - isEnabled, - value: elementType, - }); - - const createDisplayNameFilter = (source, isEnabled = true) => { - let isValid = true; - try { - new RegExp(source); - } catch (error) { - isValid = false; - } - return { - type: Types.ComponentFilterDisplayName, - isEnabled, - isValid, - value: source, - }; - }; - - const createLocationFilter = (source, isEnabled = true) => { - let isValid = true; - try { - new RegExp(source); - } catch (error) { - isValid = false; - } - return { - type: Types.ComponentFilterLocation, - isEnabled, - isValid, - value: source, - }; - }; + let store: Store; + let utils; const act = (callback: Function) => { TestUtils.act(() => { @@ -59,10 +26,11 @@ describe('Store component filters', () => { 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.startProfiling()); + act(() => store.profilerStore.startProfiling()); expect(() => (store.componentFilters = [])).toThrow( 'Cannot modify filter preferences while profiling' ); @@ -89,7 +57,7 @@ describe('Store component filters', () => { act( () => (store.componentFilters = [ - createElementTypeFilter(Types.ElementTypeHostComponent), + utils.createElementTypeFilter(Types.ElementTypeHostComponent), ]) ); @@ -98,7 +66,7 @@ describe('Store component filters', () => { act( () => (store.componentFilters = [ - createElementTypeFilter(Types.ElementTypeClass), + utils.createElementTypeFilter(Types.ElementTypeClass), ]) ); @@ -107,8 +75,8 @@ describe('Store component filters', () => { act( () => (store.componentFilters = [ - createElementTypeFilter(Types.ElementTypeClass), - createElementTypeFilter(Types.ElementTypeFunction), + utils.createElementTypeFilter(Types.ElementTypeClass), + utils.createElementTypeFilter(Types.ElementTypeFunction), ]) ); @@ -117,8 +85,8 @@ describe('Store component filters', () => { act( () => (store.componentFilters = [ - createElementTypeFilter(Types.ElementTypeClass, false), - createElementTypeFilter(Types.ElementTypeFunction, false), + utils.createElementTypeFilter(Types.ElementTypeClass, false), + utils.createElementTypeFilter(Types.ElementTypeFunction, false), ]) ); @@ -134,7 +102,7 @@ describe('Store component filters', () => { act( () => (store.componentFilters = [ - createElementTypeFilter(Types.ElementTypeRoot), + utils.createElementTypeFilter(Types.ElementTypeRoot), ]) ); @@ -159,13 +127,17 @@ describe('Store component filters', () => { ); expect(store).toMatchSnapshot('1: mount'); - act(() => (store.componentFilters = [createDisplayNameFilter('Foo')])); + act( + () => (store.componentFilters = [utils.createDisplayNameFilter('Foo')]) + ); expect(store).toMatchSnapshot('2: filter "Foo"'); - act(() => (store.componentFilters = [createDisplayNameFilter('Ba')])); + act(() => (store.componentFilters = [utils.createDisplayNameFilter('Ba')])); expect(store).toMatchSnapshot('3: filter "Ba"'); - act(() => (store.componentFilters = [createDisplayNameFilter('B.z')])); + act( + () => (store.componentFilters = [utils.createDisplayNameFilter('B.z')]) + ); expect(store).toMatchSnapshot('4: filter "B.z"'); }); @@ -178,7 +150,7 @@ describe('Store component filters', () => { act( () => (store.componentFilters = [ - createLocationFilter(__filename.replace(__dirname, '')), + utils.createLocationFilter(__filename.replace(__dirname, '')), ]) ); @@ -189,7 +161,7 @@ describe('Store component filters', () => { act( () => (store.componentFilters = [ - createLocationFilter('this:is:a:made:up:path'), + utils.createLocationFilter('this:is:a:made:up:path'), ]) ); diff --git a/src/__tests__/treeContext-test.js b/src/__tests__/treeContext-test.js new file mode 100644 index 0000000000..f9e564dfec --- /dev/null +++ b/src/__tests__/treeContext-test.js @@ -0,0 +1,550 @@ +// @flow + +import typeof ReactTestRenderer from 'react-test-renderer'; +import type Bridge 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: Bridge; + 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; + + 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: 'SET_SEARCH_TEXT', payload: 'f' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('3: search for "f"'); + + utils.act(() => dispatch({ type: 'SET_SEARCH_TEXT', payload: 'q' })); + utils.act(() => renderer.update()); + expect(state).toMatchSnapshot('4: search for "q"'); + }); + + 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(); + }); + }); +}); diff --git a/src/__tests__/utils.js b/src/__tests__/utils.js index 62ae024d71..a24d1184d8 100644 --- a/src/__tests__/utils.js +++ b/src/__tests__/utils.js @@ -2,6 +2,11 @@ import typeof ReactTestRenderer from 'react-test-renderer'; +import type Bridge 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 TestUtils = require('react-dom/test-utils'); TestUtils.act(() => { @@ -11,10 +16,10 @@ export function act(callback: Function): void { // Flush Bridge operations TestUtils.act(() => { jest.runAllTimers(); - }); + }) } -export async function actSuspense(cb: () => *): Promise { +export async function actAsync(cb: () => *): Promise { const TestUtils = require('react-dom/test-utils'); // $FlowFixMe Flow doens't know about "await act()" yet @@ -44,6 +49,56 @@ export function beforeEachProfiling(): void { ); } +export function createElementTypeFilter( + elementType: ElementType, + isEnabled: boolean = true +) { + const Types = require('src/types'); + return { + type: Types.ComponentFilterElementType, + isEnabled, + value: elementType, + }; +} + +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 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.'); @@ -67,3 +122,44 @@ export function requireTestRenderer(): ReactTestRenderer { global.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook; } } + +export function exportImportHelper(bridge: Bridge, 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; + }); +} \ No newline at end of file diff --git a/src/backend/agent.js b/src/backend/agent.js index 1a12eee29a..4b8873d475 100644 --- a/src/backend/agent.js +++ b/src/backend/agent.js @@ -16,6 +16,7 @@ import type { RendererID, RendererInterface, } from './types'; +import type { OwnersList } from 'src/devtools/views/Components/types'; import type { Bridge, ComponentFilter } from '../types'; const debug = (methodName, ...args) => { @@ -29,7 +30,7 @@ const debug = (methodName, ...args) => { } }; -type InspectSelectParams = {| +type ElementAndRendererID = {| id: number, rendererID: number, |}; @@ -92,13 +93,10 @@ export default class Agent extends EventEmitter { 'clearHighlightedElementInDOM', this.clearHighlightedElementInDOM ); - bridge.addListener('exportProfilingSummary', this.exportProfilingSummary); - bridge.addListener('getCommitDetails', this.getCommitDetails); - bridge.addListener('getFiberCommits', this.getFiberCommits); - bridge.addListener('getInteractions', this.getInteractions); + bridge.addListener('getProfilingData', this.getProfilingData); bridge.addListener('getProfilingStatus', this.getProfilingStatus); - bridge.addListener('getProfilingSummary', this.getProfilingSummary); bridge.addListener('highlightElementInDOM', this.highlightElementInDOM); + bridge.addListener('getOwnersList', this.getOwnersList); bridge.addListener('inspectElement', this.inspectElement); bridge.addListener('logElementToConsole', this.logElementToConsole); bridge.addListener('overrideContext', this.overrideContext); @@ -143,120 +141,25 @@ export default class Agent extends EventEmitter { const renderer = ((this._rendererInterfaces[ (rendererID: any) ]: any): RendererInterface); - return renderer.getFiberIDFromNative(node, true); + return renderer.getFiberIDForNative(node, true); } catch (e) {} } return null; } - exportProfilingSummary = ({ - profilingOperations, - profilingSnapshots, - rendererID, - rootID, - }: { - profilingOperations: Array, - profilingSnapshots: Array, - rendererID: number, - rootID: number, - }) => { + getProfilingData = ({ rendererID }: {| rendererID: RendererID |}) => { const renderer = this._rendererInterfaces[rendererID]; if (renderer == null) { console.warn(`Invalid renderer id "${rendererID}"`); - } else { - const rendererData = renderer.getProfilingDataForDownload(rootID); - this._bridge.send('exportFile', { - contents: JSON.stringify( - { - ...rendererData, - profilingOperations, - profilingSnapshots, - }, - null, - 2 - ), - filename: 'profile-data.json', - }); } - }; - getCommitDetails = ({ - commitIndex, - rendererID, - rootID, - }: { - commitIndex: number, - rendererID: number, - rootID: number, - }) => { - const renderer = this._rendererInterfaces[rendererID]; - if (renderer == null) { - console.warn(`Invalid renderer id "${rendererID}"`); - } else { - this._bridge.send( - 'commitDetails', - renderer.getCommitDetails(rootID, commitIndex) - ); - } - }; - - getFiberCommits = ({ - fiberID, - rendererID, - rootID, - }: { - fiberID: number, - rendererID: number, - rootID: number, - }) => { - const renderer = this._rendererInterfaces[rendererID]; - if (renderer == null) { - console.warn(`Invalid renderer id "${rendererID}"`); - } else { - this._bridge.send( - 'fiberCommits', - renderer.getFiberCommits(rootID, fiberID) - ); - } - }; - - getInteractions = ({ - rendererID, - rootID, - }: { - rendererID: number, - rootID: number, - }) => { - const renderer = this._rendererInterfaces[rendererID]; - if (renderer == null) { - console.warn(`Invalid renderer id "${rendererID}"`); - } else { - this._bridge.send('interactions', renderer.getInteractions(rootID)); - } + this._bridge.send('profilingData', renderer.getProfilingData()); }; getProfilingStatus = () => { this._bridge.send('profilingStatus', this._isProfiling); }; - getProfilingSummary = ({ - rendererID, - rootID, - }: { - rendererID: number, - rootID: number, - }) => { - const renderer = this._rendererInterfaces[rendererID]; - if (renderer == null) { - console.warn(`Invalid renderer id "${rendererID}"`); - } else { - this._bridge.send( - 'profilingSummary', - renderer.getProfilingSummary(rootID) - ); - } - }; - clearHighlightedElementInDOM = () => { hideOverlay(); }; @@ -283,7 +186,9 @@ export default class Agent extends EventEmitter { let nodes: ?Array = null; if (renderer !== null) { - nodes = ((renderer.findNativeByFiberID(id): any): ?Array); + nodes = ((renderer.findNativeNodesForFiberID( + id + ): any): ?Array); } if (nodes != null && nodes[0] != null) { @@ -303,7 +208,17 @@ export default class Agent extends EventEmitter { } }; - inspectElement = ({ id, rendererID }: InspectSelectParams) => { + 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, rendererID }: ElementAndRendererID) => { const renderer = this._rendererInterfaces[rendererID]; if (renderer == null) { console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); @@ -312,7 +227,7 @@ export default class Agent extends EventEmitter { } }; - logElementToConsole = ({ id, rendererID }: InspectSelectParams) => { + logElementToConsole = ({ id, rendererID }: ElementAndRendererID) => { const renderer = this._rendererInterfaces[rendererID]; if (renderer == null) { console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); @@ -340,13 +255,12 @@ export default class Agent extends EventEmitter { this._bridge.send('screenshotCaptured', { commitIndex, dataURL }); }; - selectElement = ({ id, rendererID }: InspectSelectParams) => { + selectElement = ({ id, rendererID }: ElementAndRendererID) => { const renderer = this._rendererInterfaces[rendererID]; if (renderer == null) { console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); } else { renderer.selectElement(id); - this._bridge.send('selectElement'); // When user selects an element, stop trying to restore the selection, // and instead remember the current selection for the next reload. @@ -461,9 +375,12 @@ export default class Agent extends EventEmitter { startInspectingDOM = () => { window.addEventListener('click', this._onClick, true); - window.addEventListener('mousedown', this._onMouseDown, true); - window.addEventListener('mouseup', this._onMouseUp, true); - window.addEventListener('mouseover', this._onMouseOver, true); + window.addEventListener('mousedown', this._onMouseEvent, true); + window.addEventListener('mouseover', this._onMouseEvent, true); + window.addEventListener('mouseup', this._onMouseEvent, true); + window.addEventListener('pointerdown', this._onPointerDown, true); + window.addEventListener('pointerover', this._onPointerOver, true); + window.addEventListener('pointerup', this._onPointerUp, true); }; startProfiling = () => { @@ -481,9 +398,12 @@ export default class Agent extends EventEmitter { hideOverlay(); window.removeEventListener('click', this._onClick, true); - window.removeEventListener('mousedown', this._onMouseDown, true); - window.removeEventListener('mouseup', this._onMouseUp, true); - window.removeEventListener('mouseover', this._onMouseOver, true); + window.removeEventListener('mousedown', this._onMouseEvent, true); + window.removeEventListener('mouseover', this._onMouseEvent, true); + window.removeEventListener('mouseup', this._onMouseEvent, true); + window.removeEventListener('pointerdown', this._onPointerDown, true); + window.removeEventListener('pointerover', this._onPointerOver, true); + window.removeEventListener('pointerup', this._onPointerUp, true); }; stopProfiling = () => { @@ -506,7 +426,7 @@ export default class Agent extends EventEmitter { } }; - viewElementSource = ({ id, rendererID }: InspectSelectParams) => { + viewElementSource = ({ id, rendererID }: ElementAndRendererID) => { const renderer = this._rendererInterfaces[rendererID]; if (renderer == null) { console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); @@ -574,25 +494,23 @@ export default class Agent extends EventEmitter { event.stopPropagation(); this.stopInspectingDOM(); + this._bridge.send('stopInspectingDOM', true); }; - _onMouseDown = (event: MouseEvent) => { + _onMouseEvent = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; + + _onPointerDown = (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); this._selectFiberForNode(((event.target: any): HTMLElement)); }; - // While we don't do anything here, this makes choosing - // the inspected element less invasive and less likely - // to dismiss e.g. a context menu. - _onMouseUp = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - }; - - _onMouseOver = (event: MouseEvent) => { + _onPointerOver = (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); @@ -605,6 +523,11 @@ export default class Agent extends EventEmitter { this._selectFiberForNode(target); }; + _onPointerUp = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; + _selectFiberForNode = throttle( memoize((node: HTMLElement) => { const id = this.getIDForNode(node); diff --git a/src/backend/index.js b/src/backend/index.js index 298d06a121..58045bc0a2 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -9,7 +9,7 @@ export function initBackend( hook: DevToolsHook, agent: Agent, global: Object -): void { +): () => void { const subs = [ hook.sub( 'renderer-attached', @@ -60,20 +60,30 @@ export function initBackend( }); // Connect any new renderers that injected themselves. - hook.on( - 'renderer', - ({ id, renderer }: { id: number, renderer: ReactRenderer }) => { - attachRenderer(id, renderer); - } + subs.push( + hook.sub( + 'renderer', + ({ id, renderer }: { id: number, renderer: ReactRenderer }) => { + attachRenderer(id, renderer); + } + ) ); hook.emit('react-devtools', agent); hook.reactDevtoolsAgent = agent; - agent.addListener('shutdown', () => { + 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/src/backend/renderer.js b/src/backend/renderer.js index c05c1dee05..70e5ecd7ce 100644 --- a/src/backend/renderer.js +++ b/src/backend/renderer.js @@ -18,7 +18,6 @@ import { ElementTypeRoot, ElementTypeSuspense, } from 'src/types'; -import { PROFILER_EXPORT_VERSION } from 'src/constants'; import { getDisplayName, getDefaultComponentFilters, @@ -37,20 +36,21 @@ import { import { inspectHooksOfFiber } from './ReactDebugHooks'; import type { - CommitDetailsBackend, + CommitDataBackend, DevToolsHook, Fiber, - FiberCommitsBackend, - InteractionBackend, - InteractionsBackend, - InteractionWithCommitsBackend, PathFrame, PathMatch, - ProfilingSummaryBackend, + ProfilingDataBackend, + ProfilingDataForRootBackend, ReactRenderer, RendererInterface, } from './types'; -import type { InspectedElement } from 'src/devtools/views/Components/types'; +import type { + InspectedElement, + Owner, +} from 'src/devtools/views/Components/types'; +import type { Interaction } from 'src/devtools/views/Profiler/types'; import type { ComponentFilter, ElementType } from 'src/types'; function getInternalReactConstants(version) { @@ -87,12 +87,27 @@ function getInternalReactConstants(version) { 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 = { + ImmediatePriority: 99, + UserBlockingPriority: 98, + NormalPriority: 97, + LowPriority: 96, + IdlePriority: 95, + NoPriority: 90, + }; + let ReactTypeOfWork; // ********************************************************** // 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, @@ -180,6 +195,7 @@ function getInternalReactConstants(version) { // ********************************************************** return { + ReactPriorityLevels, ReactTypeOfWork, ReactSymbols, ReactTypeOfSideEffect, @@ -193,6 +209,7 @@ export function attach( global: Object ): RendererInterface { const { + ReactPriorityLevels, ReactTypeOfWork, ReactSymbols, ReactTypeOfSideEffect, @@ -217,6 +234,14 @@ export function attach( SimpleMemoComponent, SuspenseComponent, } = ReactTypeOfWork; + const { + ImmediatePriority, + UserBlockingPriority, + NormalPriority, + LowPriority, + IdlePriority, + NoPriority, + } = ReactPriorityLevels; const { CONCURRENT_MODE_NUMBER, CONCURRENT_MODE_SYMBOL_STRING, @@ -446,20 +471,19 @@ export function attach( case IndeterminateComponent: return getDisplayName(resolvedType); case EventComponent: - return null; + return type.displayName || 'EventComponent'; case EventTarget: switch (getTypeSymbol(elementType.type)) { case EVENT_TARGET_TOUCH_HIT_NUMBER: case EVENT_TARGET_TOUCH_HIT_STRING: return 'TouchHitTarget'; default: - return 'EventTarget'; + return elementType.displayName || 'EventTarget'; } case ForwardRef: - const functionName = getDisplayName(resolvedType.render, ''); return ( resolvedType.displayName || - (functionName !== '' ? `ForwardRef(${functionName})` : 'ForwardRef') + getDisplayName(resolvedType.render, 'Anonymous') ); case HostRoot: return null; @@ -474,8 +498,7 @@ export function attach( if (elementType.displayName) { return elementType.displayName; } else { - const displayName = type.displayName || type.name; - return displayName ? `Memo(${displayName})` : 'Memo'; + return getDisplayName(type, 'Anonymous'); } default: const typeSymbol = getTypeSymbol(type); @@ -685,7 +708,7 @@ export function attach( pendingSimulatedUnmountedIDs.length + (pendingUnmountedRootID === null ? 0 : 1); - const ops = new Uint32Array( + const operations = new Uint32Array( // Identify which renderer this update is coming from. 2 + // [rendererID, rootFiberID] // How big is the string table? @@ -703,44 +726,44 @@ export function attach( // This enables roots to be mapped to renderers, // Which in turn enables fiber props, states, and hooks to be inspected. let i = 0; - ops[i++] = rendererID; - ops[i++] = currentRootID; // Use this ID in case the root was unmounted! + 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, ...] - ops[i++] = pendingStringTableLength; + operations[i++] = pendingStringTableLength; pendingStringTable.forEach((value, key) => { - ops[i++] = key.length; - ops.set(utfEncodeString(key), i); + operations[i++] = key.length; + operations.set(utfEncodeString(key), i); i += key.length; }); if (numUnmountIDs > 0) { // All unmounts except roots are batched in a single message. - ops[i++] = TREE_OPERATION_REMOVE; + operations[i++] = TREE_OPERATION_REMOVE; // The first number is how many unmounted IDs we're gonna send. - ops[i++] = numUnmountIDs; + 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--) { - ops[i++] = pendingRealUnmountedIDs[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. - ops.set(pendingSimulatedUnmountedIDs, i); + operations.set(pendingSimulatedUnmountedIDs, i); i += pendingSimulatedUnmountedIDs.length; // The root ID should always be unmounted last. if (pendingUnmountedRootID !== null) { - ops[i] = pendingUnmountedRootID; + operations[i] = pendingUnmountedRootID; i++; } } // Fill in the rest of the operations. - ops.set(pendingOperations, i); + operations.set(pendingOperations, i); // Let the frontend know about tree operations. // The first value in this array will identify which root it corresponds to, @@ -749,10 +772,10 @@ export function attach( // 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(ops); + pendingOperationsQueue.push(operations); } else { // If we've already connected to the frontend, just pass the operations through. - hook.emit('operations', ops); + hook.emit('operations', operations); } pendingOperations.length = 0; @@ -771,26 +794,21 @@ export function attach( if (existingID !== undefined) { return existingID; } - const id = pendingStringTable.size + 1; - pendingStringTable.set(str, id); + 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 id; + return stringID; } function recordMount(fiber: Fiber, parentFiber: Fiber | null) { const isRoot = fiber.tag === HostRoot; const id = getFiberID(getPrimaryFiber(fiber)); - const isProfilingSupported = fiber.hasOwnProperty('treeBaseDuration'); - if (isProfilingSupported) { - idToRootMap.set(id, currentRootID); - idToTreeBaseDurationMap.set(id, fiber.treeBaseDuration || 0); - } - const hasOwnerMetadata = fiber.hasOwnProperty('_debugOwner'); + const isProfilingSupported = fiber.hasOwnProperty('treeBaseDuration'); if (isRoot) { pushOperation(TREE_OPERATION_ADD); @@ -798,6 +816,12 @@ export function attach( 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); @@ -821,26 +845,10 @@ export function attach( pushOperation(keyStringID); } - if (isProfiling) { - // 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); + if (isProfilingSupported) { + idToRootMap.set(id, currentRootID); - pushOperation(TREE_OPERATION_UPDATE_TREE_BASE_DURATION); - pushOperation(id); - pushOperation(treeBaseDuration); - - const { actualDuration } = fiber; - if (actualDuration != null) { - // If profiling is active, store durations for elements that were rendered during the commit. - // We should do this for all fibers on mount, regardless of their actual durations. - const metadata = ((currentCommitProfilingMetadata: any): CommitProfilingData); - metadata.actualDurations.push(id, actualDuration); - metadata.maxActualDuration = Math.max( - metadata.maxActualDuration, - actualDuration - ); - } + recordProfilingDurations(fiber); } } @@ -990,7 +998,7 @@ export function attach( } } - function recordTreeDuration(fiber: Fiber) { + function recordProfilingDurations(fiber: Fiber) { const id = getFiberID(getPrimaryFiber(fiber)); const { actualDuration, treeBaseDuration } = fiber; @@ -1000,8 +1008,8 @@ export function attach( const { alternate } = fiber; if ( - treeBaseDuration !== - (alternate ? alternate.treeBaseDuration : undefined) + 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. @@ -1009,18 +1017,31 @@ export function attach( (fiber.treeBaseDuration || 0) * 1000 ); pushOperation(TREE_OPERATION_UPDATE_TREE_BASE_DURATION); - pushOperation(getFiberID(getPrimaryFiber(fiber))); + pushOperation(id); pushOperation(treeBaseDuration); } - if (alternate ? hasDataChanged(alternate, fiber) : true) { + if (alternate == null || hasDataChanged(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.actualDurations.push(id, actualDuration); + metadata.durations.push(id, actualDuration, selfDuration); metadata.maxActualDuration = Math.max( metadata.maxActualDuration, actualDuration @@ -1081,6 +1102,18 @@ export function attach( if (__DEBUG__) { debug('updateFiberRecursively()', nextFiber, parentFiber); } + + if ( + mostRecentlyInspectedElementID !== null && + mostRecentlyInspectedElementID === + getFiberID(getPrimaryFiber(nextFiber)) && + hasDataChanged(prevFiber, nextFiber) + ) { + // If this Fiber has updated, clear cached inspected data. + // If it is inspected again, it may need to be re-run to obtain updated hooks values. + hasElementUpdatedSinceLastInspected = true; + } + const shouldIncludeInTree = !shouldFilterFiber(nextFiber); const isSuspense = nextFiber.tag === SuspenseComponent; let shouldResetChildren = false; @@ -1202,7 +1235,7 @@ export function attach( if (shouldIncludeInTree) { const isProfilingSupported = nextFiber.hasOwnProperty('treeBaseDuration'); if (isProfilingSupported) { - recordTreeDuration(nextFiber); + recordProfilingDurations(nextFiber); } } if (shouldResetChildren) { @@ -1246,8 +1279,8 @@ export function attach( ) { // We may have already queued up some operations before the frontend connected // If so, let the frontend know about them. - localPendingOperationsQueue.forEach(ops => { - hook.emit('operations', ops); + localPendingOperationsQueue.forEach(operations => { + hook.emit('operations', operations); }); } else { // Before the traversals, remember to start tracking @@ -1264,15 +1297,16 @@ export function attach( // If profiling is active, store commit time and duration, and the current interactions. // The frontend may request this information after profiling has stopped. currentCommitProfilingMetadata = { - actualDurations: [], + durations: [], commitTime: performance.now() - profilingStartTime, interactions: Array.from(root.memoizedInteractions).map( - (interaction: InteractionBackend) => ({ + (interaction: Interaction) => ({ ...interaction, timestamp: interaction.timestamp - profilingStartTime, }) ), maxActualDuration: 0, + priorityLevel: null, }; } @@ -1290,7 +1324,7 @@ export function attach( recordUnmount(fiber, false); } - function handleCommitFiberRoot(root) { + function handleCommitFiberRoot(root, priorityLevel) { const current = root.current; const alternate = current.alternate; @@ -1306,15 +1340,17 @@ export function attach( // If profiling is active, store commit time and duration, and the current interactions. // The frontend may request this information after profiling has stopped. currentCommitProfilingMetadata = { - actualDurations: [], + durations: [], commitTime: performance.now() - profilingStartTime, interactions: Array.from(root.memoizedInteractions).map( - (interaction: InteractionBackend) => ({ + (interaction: Interaction) => ({ ...interaction, timestamp: interaction.timestamp - profilingStartTime, }) ), maxActualDuration: 0, + priorityLevel: + priorityLevel == null ? null : formatPriorityLevel(priorityLevel), }; } @@ -1399,7 +1435,7 @@ export function attach( return fibers; } - function findNativeByFiberID(id: number) { + function findNativeNodesForFiberID(id: number) { try { let fiber = findCurrentFiberUsingSlowPathById(id); if (fiber === null) { @@ -1424,7 +1460,7 @@ export function attach( } } - function getFiberIDFromNative( + function getFiberIDForNative( hostInstance, findNearestUnfilteredAncestor = false ) { @@ -1636,7 +1672,7 @@ export function attach( return; } - const { memoizedProps, stateNode, tag, type } = fiber; + const { elementType, memoizedProps, stateNode, tag, type } = fiber; switch (tag) { case ClassComponent: @@ -1656,6 +1692,16 @@ export function attach( type: type.render, }; break; + case MemoComponent: + case SimpleMemoComponent: + global.$r = { + props: memoizedProps, + type: + elementType != null && elementType.type != null + ? elementType.type + : type, + }; + break; default: global.$r = null; break; @@ -1669,15 +1715,24 @@ export function attach( return; } - switch (fiber.tag) { + const { elementType, tag, type } = fiber; + + switch (tag) { case ClassComponent: case IncompleteClassComponent: case IndeterminateComponent: case FunctionComponent: - global.$type = fiber.type; + global.$type = type; break; case ForwardRef: - global.$type = fiber.type.render; + global.$type = type.render; + break; + case MemoComponent: + case SimpleMemoComponent: + global.$type = + elementType != null && elementType.type != null + ? elementType.type + : type; break; default: global.$type = null; @@ -1685,6 +1740,35 @@ export function attach( } } + 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, + }, + ]; + + if (_debugOwner) { + let owner = _debugOwner; + while (owner !== null) { + owners.unshift({ + displayName: getDisplayNameForFiber(owner) || 'Anonymous', + id: getFiberID(getPrimaryFiber(owner)), + }); + owner = owner._debugOwner || null; + } + } + + return owners; + } + function inspectElementRaw(id: number): InspectedElement | null { let fiber = findCurrentFiberUsingSlowPathById(id); if (fiber == null) { @@ -1716,7 +1800,9 @@ export function attach( tag === FunctionComponent || tag === IncompleteClassComponent || tag === IndeterminateComponent || - tag === ForwardRef + tag === MemoComponent || + tag === ForwardRef || + tag === SimpleMemoComponent ) { canViewSource = true; if (stateNode && stateNode.context != null) { @@ -1770,7 +1856,7 @@ export function attach( let owner = _debugOwner; while (owner !== null) { owners.push({ - displayName: getDisplayNameForFiber(owner) || 'Unknown', + displayName: getDisplayNameForFiber(owner) || 'Anonymous', id: getFiberID(getPrimaryFiber(owner)), }); owner = owner._debugOwner || null; @@ -1780,6 +1866,23 @@ export function attach( const isTimedOutSuspense = tag === SuspenseComponent && memoizedState !== null; + let events = null; + let node = fiber; + while (node !== null) { + if (node.tag === EventComponent) { + if (events === null) { + events = []; + } + const eventComponentInstance = node.stateNode; + const currentFiber = eventComponentInstance.currentFiber; + events.push({ + props: eventComponentInstance.props, + displayName: getDisplayNameForFiber(currentFiber), + }); + } + node = node.return; + } + return { id, @@ -1805,6 +1908,7 @@ export function attach( // Inspectable properties. // TODO Review sanitization approach for the below inspectable values. context, + events, hooks: usesHooks ? inspectHooksOfFiber(fiber, (renderer.currentDispatcherRef: any)) : null, @@ -1819,17 +1923,33 @@ export function attach( }; } - function inspectElement(id: number): InspectedElement | null { - let result = inspectElementRaw(id); - if (result === null) { + let mostRecentlyInspectedElementID: number | null = null; + let hasElementUpdatedSinceLastInspected: boolean = false; + + function inspectElement(id: number): InspectedElement | number | null { + // 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. + if ( + mostRecentlyInspectedElementID === id && + !hasElementUpdatedSinceLastInspected + ) { + return id; + } + + mostRecentlyInspectedElementID = id; + hasElementUpdatedSinceLastInspected = false; + + const inspectedElement = inspectElementRaw(id); + if (inspectedElement === null) { return null; } - // TODO Review sanitization approach for the below inspectable values. - result.context = cleanForBridge(result.context); - result.hooks = cleanForBridge(result.hooks); - result.props = cleanForBridge(result.props); - result.state = cleanForBridge(result.state); - return result; + inspectedElement.context = cleanForBridge(inspectedElement.context); + inspectedElement.events = cleanForBridge(inspectedElement.events); + inspectedElement.hooks = cleanForBridge(inspectedElement.hooks); + inspectedElement.props = cleanForBridge(inspectedElement.props); + inspectedElement.state = cleanForBridge(inspectedElement.state); + + return inspectedElement; } function logElementToConsole(id) { @@ -1856,7 +1976,7 @@ export function attach( if (result.hooks !== null) { console.log('Hooks:', result.hooks); } - const nativeNodes = findNativeByFiberID(id); + const nativeNodes = findNativeNodesForFiberID(id); if (nativeNodes !== null) { console.log('Nodes:', nativeNodes); } @@ -1928,184 +2048,115 @@ export function attach( } type CommitProfilingData = {| - actualDurations: Array, commitTime: number, - interactions: Array, + 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 initialTreeBaseDurationsMap: Map | null = null; let initialIDToRootMap: Map | null = null; let isProfiling: boolean = false; let profilingStartTime: number = 0; let rootToCommitProfilingMetadataMap: CommitProfilingMetadataMap | null = null; - function getCommitDetails( - rootID: number, - commitIndex: number - ): CommitDetailsBackend { - const commitProfilingMetadata = ((rootToCommitProfilingMetadataMap: any): CommitProfilingMetadataMap).get( - rootID - ); - if (commitProfilingMetadata != null) { - const commitProfilingData = commitProfilingMetadata[commitIndex]; - if (commitProfilingData != null) { - return { - commitIndex, - interactions: commitProfilingData.interactions, - actualDurations: commitProfilingData.actualDurations, + 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 { + 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({ + duration: maxActualDuration, + fiberActualDurations, + fiberSelfDurations, + interactionIDs, + priorityLevel, + timestamp: commitTime, + }); + }); + + dataForRoots.push({ + commitData, + displayName, + initialTreeBaseDurations, + interactionCommits: Array.from(interactionCommits.entries()), + interactions: Array.from(allInteractions.entries()), rootID, - }; - } - } - - console.warn( - `getCommitDetails(): No profiling info recorded for root "${rootID}" and commit ${commitIndex}` - ); - - return { - commitIndex, - interactions: [], - actualDurations: [], - rootID, - }; - } - - function getFiberCommits( - rootID: number, - fiberID: number - ): FiberCommitsBackend { - const commitProfilingMetadata = ((rootToCommitProfilingMetadataMap: any): CommitProfilingMetadataMap).get( - rootID - ); - if (commitProfilingMetadata != null) { - const commitDurations = []; - commitProfilingMetadata.forEach(({ actualDurations }, commitIndex) => { - for (let i = 0; i < actualDurations.length; i += 2) { - if (actualDurations[i] === fiberID) { - commitDurations.push(commitIndex, actualDurations[i + 1]); - break; - } - } - }); - - return { - commitDurations, - fiberID, - rootID, - }; - } - - console.warn( - `getFiberCommits(): No profiling info recorded for root "${rootID}"` - ); - - return { - commitDurations: [], - fiberID, - rootID, - }; - } - - function getInteractions(rootID: number): InteractionsBackend { - const commitProfilingMetadata = ((rootToCommitProfilingMetadataMap: any): CommitProfilingMetadataMap).get( - rootID - ); - if (commitProfilingMetadata != null) { - const interactionsMap: Map< - number, - InteractionWithCommitsBackend - > = new Map(); - - commitProfilingMetadata.forEach((commitProfilingData, commitIndex) => { - commitProfilingData.interactions.forEach(interaction => { - const interactionWithCommits = interactionsMap.get(interaction.id); - if (interactionWithCommits != null) { - interactionWithCommits.commits.push(commitIndex); - } else { - interactionsMap.set(interaction.id, { - ...interaction, - commits: [commitIndex], - }); - } }); - }); - - return { - interactions: Array.from(interactionsMap.values()), - rootID, - }; - } - - console.warn( - `getInteractions(): No interactions recorded for root "${rootID}"` - ); - - return { - interactions: [], - rootID, - }; - } - - function getProfilingDataForDownload(rootID: number): Object { - const commitDetails = []; - const commitProfilingMetadata = ((rootToCommitProfilingMetadataMap: any): CommitProfilingMetadataMap).get( - rootID - ); - if (commitProfilingMetadata != null) { - for (let index = 0; index < commitProfilingMetadata.length; index++) { - commitDetails.push(getCommitDetails(rootID, index)); } - } - return { - version: PROFILER_EXPORT_VERSION, - profilingSummary: getProfilingSummary(rootID), - commitDetails, - interactions: getInteractions(rootID), - }; - } - - function getProfilingSummary(rootID: number): ProfilingSummaryBackend { - const interactions = new Set(); - const commitDurations = []; - const commitTimes = []; - - const commitProfilingMetadata = ((rootToCommitProfilingMetadataMap: any): CommitProfilingMetadataMap).get( - rootID ); - if (commitProfilingMetadata != null) { - commitProfilingMetadata.forEach(metadata => { - commitDurations.push(metadata.maxActualDuration); - commitTimes.push(metadata.commitTime); - metadata.interactions.forEach(({ name, timestamp }) => { - interactions.add(`${timestamp}:${name}`); - }); - }); - } - - const initialTreeBaseDurations = []; - 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); - } - }); - } return { - commitDurations, - commitTimes, - initialTreeBaseDurations, - interactionCount: interactions.size, - rootID, + dataForRoots, + rendererID, }; } @@ -2118,9 +2169,18 @@ export function attach( // 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); + hook.getFiberRoots(rendererID).forEach(root => { + const rootID = getFiberID(getPrimaryFiber(root.current)); + ((displayNamesByRootID: any): DisplayNamesByRootID).set( + rootID, + getDisplayNameForRoot(root.current) + ); + }); + isProfiling = true; profilingStartTime = performance.now(); rootToCommitProfilingMetadataMap = new Map(); @@ -2256,6 +2316,32 @@ export function attach( 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; @@ -2282,29 +2368,7 @@ export function attach( } child = child.child; } - const name = preferredDisplayName || fallbackDisplayName || 'Unknown'; - 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); + return preferredDisplayName || fallbackDisplayName || 'Anonymous'; } function getPathFrame(fiber: Fiber): PathFrame { @@ -2376,18 +2440,37 @@ export function attach( }; } + 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, flushInitialOperations, getBestMatchForTrackedPath, - getCommitDetails, - getFiberIDFromNative, - getFiberCommits, - getInteractions, - findNativeByFiberID, + getFiberIDForNative, + findNativeNodesForFiberID, + getOwnersList, getPathForElement, - getProfilingDataForDownload, - getProfilingSummary, + getProfilingData, handleCommitFiberRoot, handleCommitFiberUnmount, inspectElement, diff --git a/src/backend/types.js b/src/backend/types.js index 7cd529528b..8972fbd4e2 100644 --- a/src/backend/types.js +++ b/src/backend/types.js @@ -1,7 +1,11 @@ // @flow import type { ComponentFilter, ElementType } from 'src/types'; -import type { InspectedElement } from 'src/devtools/views/Components/types'; +import type { + InspectedElement, + Owner, +} from 'src/devtools/views/Components/types'; +import type { Interaction } from 'src/devtools/views/Profiler/types'; type BundleType = | 0 // PROD @@ -112,41 +116,33 @@ export type ReactRenderer = { currentDispatcherRef?: {| current: null | Dispatcher |}, }; -export type InteractionBackend = {| - id: number, - name: string, +export type CommitDataBackend = {| + 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 CommitDetailsBackend = {| - actualDurations: Array, - commitIndex: number, - interactions: Array, +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, |}; -export type FiberCommitsBackend = {| - commitDurations: Array, - fiberID: number, - rootID: number, -|}; - -export type InteractionWithCommitsBackend = {| - ...InteractionBackend, - commits: Array, -|}; - -export type InteractionsBackend = {| - interactions: Array, - rootID: number, -|}; - -export type ProfilingSummaryBackend = {| - commitDurations: Array, - commitTimes: Array, - initialTreeBaseDurations: Array, - interactionCount: number, - 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 = {| @@ -162,25 +158,19 @@ export type PathMatch = {| export type RendererInterface = { cleanup: () => void, - findNativeByFiberID: (id: number) => ?Array, + findNativeNodesForFiberID: (id: number) => ?Array, flushInitialOperations: () => void, getBestMatchForTrackedPath: () => PathMatch | null, - getCommitDetails: ( - rootID: number, - commitIndex: number - ) => CommitDetailsBackend, - getFiberIDFromNative: ( + getFiberIDForNative: ( component: NativeType, findNearestUnfilteredAncestor?: boolean ) => number | null, - getFiberCommits: (rootID: number, fiberID: number) => FiberCommitsBackend, - getInteractions: (rootID: number) => InteractionsBackend, - getProfilingDataForDownload: (rootID: number) => Object, - getProfilingSummary: (rootID: number) => ProfilingSummaryBackend, + getProfilingData(): ProfilingDataBackend, + getOwnersList: (id: number) => Array | null, getPathForElement: (id: number) => Array | null, - handleCommitFiberRoot: (fiber: Object) => void, + handleCommitFiberRoot: (fiber: Object, commitPriority?: number) => void, handleCommitFiberUnmount: (fiber: Object) => void, - inspectElement: (id: number) => InspectedElement | null, + inspectElement: (id: number) => InspectedElement | number | null, logElementToConsole: (id: number) => void, overrideSuspense: (id: number, forceFallback: boolean) => void, prepareViewElementSource: (id: number) => void, @@ -219,7 +209,11 @@ export type DevToolsHook = { // React uses these methods. checkDCE: (fn: Function) => void, onCommitFiberUnmount: (rendererID: RendererID, fiber: Object) => void, - onCommitFiberRoot: (rendererID: RendererID, fiber: Object) => void, + onCommitFiberRoot: ( + rendererID: RendererID, + fiber: Object, + commitPriority?: number + ) => void, }; export type HooksNode = { diff --git a/src/bridge.js b/src/bridge.js index a839db5323..1619e51852 100644 --- a/src/bridge.js +++ b/src/bridge.js @@ -57,6 +57,9 @@ export default class Bridge extends EventEmitter { 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; @@ -74,9 +77,6 @@ export default class Bridge extends EventEmitter { wallUnlisten(); } - // Queue the shutdown outgoing message for subscribers. - this.send('shutdown'); - // Synchronously flush all queued outgoing messages. // At this step the subscribers' code may run in this call stack. do { diff --git a/src/constants.js b/src/constants.js index 0ba0cb8b69..3097a0749e 100644 --- a/src/constants.js +++ b/src/constants.js @@ -16,4 +16,4 @@ export const SESSION_STORAGE_LAST_SELECTION_KEY = export const __DEBUG__ = false; -export const PROFILER_EXPORT_VERSION = 2; +export const PROFILER_EXPORT_VERSION = 4; diff --git a/src/devtools/ProfilerStore.js b/src/devtools/ProfilerStore.js new file mode 100644 index 0000000000..f4901287ea --- /dev/null +++ b/src/devtools/ProfilerStore.js @@ -0,0 +1,376 @@ +// @flow + +import EventEmitter from 'events'; +import memoize from 'memoize-one'; +import throttle from 'lodash.throttle'; +import { prepareProfilingDataFrontendFromBackendAndStore } from './views/Profiler/utils'; +import ProfilingCache from './ProfilingCache'; +import Store from './store'; + +import type { ProfilingDataBackend } from 'src/backend/types'; +import type { + CommitDataFrontend, + ProfilingDataForRootFrontend, + ProfilingDataFrontend, + SnapshotNode, +} from './views/Profiler/types'; +import type { Bridge } from '../types'; + +const THROTTLE_CAPTURE_SCREENSHOT_DURATION = 500; + +export default class ProfilerStore extends EventEmitter { + _bridge: Bridge; + + // 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(); + + // Map of root (id) to a Map of screenshots by commit ID. + // Stores screenshots for each commit (when profiling). + // + // This map is only updated while profiling is in progress; + // Upon completion, it is converted into the exportable ProfilingDataFrontend format. + _inProgressScreenshotsByRootID: 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: Bridge, 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._inProgressScreenshotsByRootID.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._inProgressScreenshotsByRootID.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'); + + // 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. + } + + _captureScreenshot = throttle( + memoize((rootID: number, commitIndex: number) => { + this._bridge.send('captureScreenshot', { commitIndex, rootID }); + }), + THROTTLE_CAPTURE_SCREENSHOT_DURATION + ); + + _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: Uint32Array) => { + if (!(operations instanceof Uint32Array)) { + // $FlowFixMe TODO HACK Temporary workaround for the fact that Chrome is not transferring the typed array. + operations = Uint32Array.from(Object.values(operations)); + } + + // 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()); + } + + if (this._store.captureScreenshots) { + const commitIndex = profilingOperations.length - 1; + this._captureScreenshot(rootID, commitIndex); + } + } + }; + + 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._inProgressScreenshotsByRootID, + 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._inProgressScreenshotsByRootID.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'); + } + } + }; + + onScreenshotCaptured = ({ + commitIndex, + dataURL, + rootID, + }: {| + commitIndex: number, + dataURL: string, + rootID: number, + |}) => { + let screenshotsForRootByCommitIndex = this._inProgressScreenshotsByRootID.get( + rootID + ); + if (!screenshotsForRootByCommitIndex) { + screenshotsForRootByCommitIndex = new Map(); + this._inProgressScreenshotsByRootID.set( + rootID, + screenshotsForRootByCommitIndex + ); + } + screenshotsForRootByCommitIndex.set(commitIndex, dataURL); + }; +} diff --git a/src/devtools/ProfilingCache.js b/src/devtools/ProfilingCache.js index 9ef88565c8..d8f1819ddb 100644 --- a/src/devtools/ProfilingCache.js +++ b/src/devtools/ProfilingCache.js @@ -1,7 +1,6 @@ // @flow -import { createResource } from './cache'; -import Store from './store'; +import ProfilerStore from './ProfilerStore'; import { getCommitTree, invalidateCommitTrees, @@ -19,395 +18,105 @@ import { invalidateChartData as invalidateRankedChartData, } from 'src/devtools/views/Profiler/RankedChartBuilder'; -import type { Resource } from './cache'; -import type { Bridge } from '../types'; -import type { - CommitDetailsBackend, - FiberCommitsBackend, - InteractionsBackend, - ProfilingSummaryBackend, -} from 'src/backend/types'; -import type { - CommitDetailsFrontend, - FiberCommitsFrontend, - InteractionsFrontend, - InteractionWithCommitsFrontend, - CommitTreeFrontend, - ProfilingSummaryFrontend, -} from 'src/devtools/views/Profiler/types'; +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'; -type CommitDetailsParams = {| - commitIndex: number, - rendererID: number, - rootID: number, -|}; - -type FiberCommitsParams = {| - fiberID: number, - rendererID: number, - rootID: number, -|}; - -type InteractionsParams = {| - rendererID: number, - rootID: number, -|}; - -type GetCommitTreeParams = {| - commitIndex: number, - profilingSummary: ProfilingSummaryFrontend, -|}; - -type ProfilingSummaryParams = {| - rendererID: number, - rootID: number, -|}; - export default class ProfilingCache { - _bridge: Bridge; - _store: Store; + _fiberCommits: Map> = new Map(); + _profilerStore: ProfilerStore; - _pendingCommitDetailsMap: Map< - string, - (commitDetails: CommitDetailsFrontend) => void - > = new Map(); - - _pendingFiberCommitsMap: Map< - string, - (fiberCommits: FiberCommitsFrontend) => void - > = new Map(); - - _pendingInteractionsMap: Map< - number, - (interactions: InteractionsFrontend) => void - > = new Map(); - - _pendingProfileSummaryMap: Map< - number, - (profilingSummary: ProfilingSummaryFrontend) => void - > = new Map(); - - CommitDetails: Resource< - CommitDetailsParams, - string, - CommitDetailsFrontend - > = createResource( - ({ commitIndex, rendererID, rootID }: CommitDetailsParams) => { - return new Promise(resolve => { - const importedProfilingData = this._store.importedProfilingData; - if (importedProfilingData !== null) { - const { commitDetails } = (importedProfilingData: any); - if (commitDetails != null && commitIndex < commitDetails.length) { - const response = commitDetails[commitIndex]; - this._pendingCommitDetailsMap.set( - `${response.rootID}-${commitIndex}`, - resolve - ); - this.onCommitDetails(response); - return; - } - } else if (this._store.profilingOperations.has(rootID)) { - this._pendingCommitDetailsMap.set( - `${rootID}-${commitIndex}`, - resolve - ); - this._bridge.send('getCommitDetails', { - commitIndex, - rendererID, - rootID, - }); - return; - } - - // If no profiling data was recorded for this root, skip the round trip. - resolve({ - rootID, - commitIndex, - actualDurations: new Map(), - interactions: [], - }); - }); - }, - ({ commitIndex, rendererID, rootID }: CommitDetailsParams) => - `${rootID}-${commitIndex}` - ); - - FiberCommits: Resource< - FiberCommitsParams, - string, - FiberCommitsFrontend - > = createResource( - ({ fiberID, rendererID, rootID }: FiberCommitsParams) => { - return new Promise(resolve => { - const importedProfilingData = this._store.importedProfilingData; - if (importedProfilingData !== null) { - const { commitDetails } = (importedProfilingData: any); - if (commitDetails != null) { - const commitDurations = []; - commitDetails.forEach(({ actualDurations }, commitIndex) => { - for (let i = 0; i < actualDurations.length; i += 2) { - if (actualDurations[i] === fiberID) { - commitDurations.push(commitIndex, actualDurations[i + 1]); - break; - } - } - }); - this._pendingFiberCommitsMap.set(`${rootID}-${fiberID}`, resolve); - this.onFiberCommits({ - commitDurations, - fiberID, - rootID, - }); - return; - } - } else if (this._store.profilingOperations.has(rootID)) { - this._pendingFiberCommitsMap.set(`${rootID}-${fiberID}`, resolve); - this._bridge.send('getFiberCommits', { - fiberID, - rendererID, - rootID, - }); - return; - } - - // If no profiling data was recorded for this root, skip the round trip. - resolve({ - commitDurations: [], - fiberID, - rootID, - }); - }); - }, - ({ fiberID, rendererID, rootID }: FiberCommitsParams) => - `${rootID}-${fiberID}` - ); - - Interactions: Resource< - InteractionsParams, - number, - InteractionsFrontend - > = createResource( - ({ rendererID, rootID }: InteractionsParams) => { - return new Promise(resolve => { - const importedProfilingData = this._store.importedProfilingData; - if (importedProfilingData !== null) { - const { interactions } = (importedProfilingData: any); - if (interactions != null) { - this._pendingInteractionsMap.set(interactions.rootID, resolve); - this.onInteractions(interactions); - return; - } - } else if (this._store.profilingOperations.has(rootID)) { - this._pendingInteractionsMap.set(rootID, resolve); - this._bridge.send('getInteractions', { - rendererID, - rootID, - }); - return; - } - - // If no profiling data was recorded for this root, skip the round trip. - resolve([]); - }); - }, - ({ rendererID, rootID }: ProfilingSummaryParams) => rootID - ); - - ProfilingSummary: Resource< - ProfilingSummaryParams, - number, - ProfilingSummaryFrontend - > = createResource( - ({ rendererID, rootID }: ProfilingSummaryParams) => { - return new Promise(resolve => { - const importedProfilingData = this._store.importedProfilingData; - if (importedProfilingData !== null) { - const { profilingSummary } = (importedProfilingData: any); - if (profilingSummary != null) { - this._pendingProfileSummaryMap.set( - profilingSummary.rootID, - resolve - ); - this.onProfileSummary(profilingSummary); - return; - } - } else if (this._store.profilingOperations.has(rootID)) { - this._pendingProfileSummaryMap.set(rootID, resolve); - this._bridge.send('getProfilingSummary', { rendererID, rootID }); - return; - } - - // If no profiling data was recorded for this root, skip the round trip. - resolve({ - rootID, - commitDurations: [], - commitTimes: [], - initialTreeBaseDurations: new Map(), - interactionCount: 0, - }); - }); - }, - ({ rendererID, rootID }: ProfilingSummaryParams) => rootID - ); - - constructor(bridge: Bridge, store: Store) { - this._bridge = bridge; - this._store = store; - - bridge.addListener('commitDetails', this.onCommitDetails); - bridge.addListener('fiberCommits', this.onFiberCommits); - bridge.addListener('interactions', this.onInteractions); - bridge.addListener('profilingSummary', this.onProfileSummary); + constructor(profilerStore: ProfilerStore) { + this._profilerStore = profilerStore; } - getCommitTree = ({ commitIndex, profilingSummary }: GetCommitTreeParams) => + getCommitTree = ({ + commitIndex, + rootID, + }: {| + commitIndex: number, + rootID: number, + |}) => getCommitTree({ commitIndex, - profilingSummary, - store: this._store, + 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 = ({ - commitDetails, commitIndex, commitTree, + rootID, }: {| - commitDetails: CommitDetailsFrontend, commitIndex: number, - commitTree: CommitTreeFrontend, + commitTree: CommitTree, + rootID: number, |}): FlamegraphChartData => getFlamegraphChartData({ - commitDetails, commitIndex, commitTree, + profilerStore: this._profilerStore, + rootID, }); getInteractionsChartData = ({ - interactions, - profilingSummary, rootID, }: {| - interactions: Array, - profilingSummary: ProfilingSummaryFrontend, + rootID: number, |}): InteractionsChartData => getInteractionsChartData({ - interactions, - profilingSummary, + profilerStore: this._profilerStore, + rootID, }); getRankedChartData = ({ - commitDetails, commitIndex, commitTree, + rootID, }: {| - commitDetails: CommitDetailsFrontend, commitIndex: number, - commitTree: CommitTreeFrontend, + commitTree: CommitTree, + rootID: number, |}): RankedChartData => getRankedChartData({ - commitDetails, commitIndex, commitTree, + profilerStore: this._profilerStore, + rootID, }); invalidate() { - // Invalidate Suspense caches. - this.CommitDetails.clear(); - this.FiberCommits.clear(); - this.Interactions.clear(); - this.ProfilingSummary.clear(); + this._fiberCommits.clear(); - // Invalidate non-Suspense caches too. invalidateCommitTrees(); invalidateFlamegraphChartData(); invalidateInteractionsChartData(); invalidateRankedChartData(); - - this._pendingCommitDetailsMap.clear(); - this._pendingProfileSummaryMap.clear(); } - - onCommitDetails = ({ - commitIndex, - actualDurations, - interactions, - rootID, - }: CommitDetailsBackend) => { - const key = `${rootID}-${commitIndex}`; - const resolve = this._pendingCommitDetailsMap.get(key); - if (resolve != null) { - this._pendingCommitDetailsMap.delete(key); - - const actualDurationsMap = new Map(); - for (let i = 0; i < actualDurations.length; i += 2) { - actualDurationsMap.set(actualDurations[i], actualDurations[i + 1]); - } - - resolve({ - rootID, - commitIndex, - actualDurations: actualDurationsMap, - interactions, - }); - } - }; - - onFiberCommits = ({ - commitDurations, - fiberID, - rootID, - }: FiberCommitsBackend) => { - const key = `${rootID}-${fiberID}`; - const resolve = this._pendingFiberCommitsMap.get(key); - if (resolve != null) { - this._pendingFiberCommitsMap.delete(key); - - resolve({ - commitDurations, - fiberID, - rootID, - }); - } - }; - - onInteractions = ({ interactions, rootID }: InteractionsBackend) => { - const resolve = this._pendingInteractionsMap.get(rootID); - if (resolve != null) { - this._pendingInteractionsMap.delete(rootID); - - resolve(interactions); - } - }; - - onProfileSummary = ({ - commitDurations, - commitTimes, - initialTreeBaseDurations, - interactionCount, - rootID, - }: ProfilingSummaryBackend) => { - const resolve = this._pendingProfileSummaryMap.get(rootID); - if (resolve != null) { - this._pendingProfileSummaryMap.delete(rootID); - - const initialTreeBaseDurationsMap = new Map(); - for (let i = 0; i < initialTreeBaseDurations.length; i += 2) { - initialTreeBaseDurationsMap.set( - initialTreeBaseDurations[i], - initialTreeBaseDurations[i + 1] - ); - } - - resolve({ - rootID, - commitDurations, - commitTimes, - initialTreeBaseDurations: initialTreeBaseDurationsMap, - interactionCount, - }); - } - }; } diff --git a/src/devtools/store.js b/src/devtools/store.js index 61a141797e..c3a8ccabb9 100644 --- a/src/devtools/store.js +++ b/src/devtools/store.js @@ -1,8 +1,6 @@ // @flow import EventEmitter from 'events'; -import memoize from 'memoize-one'; -import throttle from 'lodash.throttle'; import { inspect } from 'util'; import { TREE_OPERATION_ADD, @@ -17,14 +15,10 @@ import { utfDecodeString, } from '../utils'; import { __DEBUG__ } from '../constants'; -import ProfilingCache from './ProfilingCache'; import { printStore } from 'src/__tests__/storeSerializer'; +import ProfilerStore from './ProfilerStore'; import type { Element } from './views/Components/types'; -import type { - ImportedProfilingData, - ProfilingSnapshotNode, -} from './views/Profiler/types'; import type { Bridge, ComponentFilter, ElementType } from '../types'; const debug = (methodName, ...args) => { @@ -43,12 +37,9 @@ const LOCAL_STORAGE_CAPTURE_SCREENSHOTS_KEY = const LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY = 'React::DevTools::collapseNodesByDefault'; -const THROTTLE_CAPTURE_SCREENSHOT_DURATION = 500; - type Config = {| isProfiling?: boolean, supportsCaptureScreenshots?: boolean, - supportsFileDownloads?: boolean, supportsReloadAndProfile?: boolean, supportsProfiling?: boolean, |}; @@ -80,37 +71,11 @@ export default class Store extends EventEmitter { // The InspectedElementContext also relies on this mutability for its WeakMap usage. _idToElement: Map = new Map(); - // The user has imported a previously exported profiling session. - _importedProfilingData: ImportedProfilingData | null = null; - - // 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; - // 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(); - // Suspense cache for reading profiling data. - _profilingCache: ProfilingCache; - - // 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. - _profilingOperationsByRootID: Map> = new Map(); - - // Map of root (id) to a Map of screenshots by commit ID. - // Stores screenshots for each commit (when profiling). - _profilingScreenshotsByRootID: Map> = new Map(); - - // 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. - _profilingSnapshotsByRootID: Map< - number, - Map - > = new Map(); + _profilerStore: ProfilerStore; // Incremented each time the store is mutated. // This enables a passive effect to detect a mutation between render and commit phase. @@ -128,7 +93,6 @@ export default class Store extends EventEmitter { // 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. _supportsCaptureScreenshots: boolean = false; - _supportsFileDownloads: boolean = false; _supportsProfiling: boolean = false; _supportsReloadAndProfile: boolean = false; @@ -150,26 +114,21 @@ export default class Store extends EventEmitter { this._componentFilters = getSavedComponentFilters(); + let isProfiling = false; if (config != null) { + isProfiling = config.isProfiling === true; + const { - isProfiling, supportsCaptureScreenshots, - supportsFileDownloads, supportsProfiling, supportsReloadAndProfile, } = config; - if (isProfiling) { - this._isProfiling = true; - } if (supportsCaptureScreenshots) { this._supportsCaptureScreenshots = true; this._captureScreenshots = localStorage.getItem(LOCAL_STORAGE_CAPTURE_SCREENSHOTS_KEY) === 'true'; } - if (supportsFileDownloads) { - this._supportsFileDownloads = true; - } if (supportsProfiling) { this._supportsProfiling = true; } @@ -180,15 +139,9 @@ export default class Store extends EventEmitter { this._bridge = bridge; bridge.addListener('operations', this.onBridgeOperations); - bridge.addListener('profilingStatus', this.onProfilingStatus); - bridge.addListener('screenshotCaptured', this.onScreenshotCaptured); 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._profilingCache = new ProfilingCache(bridge, this); + this._profilerStore = new ProfilerStore(bridge, this, isProfiling); } // This is only used in tests to avoid memory leaks. @@ -197,23 +150,6 @@ export default class Store extends EventEmitter { // 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 will be empty unless profiling mode has been started. - // After this, their size should always match the number of roots, - // but unless we want to track additional metadata about profiling history, - // the only safe time to assert this is when the store is empty. - this.assertMapSizeMatchesRootCount( - this._profilingOperationsByRootID, - '_profilingOperationsByRootID' - ); - this.assertMapSizeMatchesRootCount( - this._profilingScreenshotsByRootID, - '_profilingScreenshotsByRootID' - ); - this.assertMapSizeMatchesRootCount( - this._profilingSnapshotsByRootID, - '_profilingSnapshotsByRootID' - ); } // These maps should always be the same size as the number of roots @@ -273,7 +209,7 @@ export default class Store extends EventEmitter { return this._componentFilters; } set componentFilters(value: Array): void { - if (this._isProfiling) { + 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'); @@ -295,55 +231,22 @@ export default class Store extends EventEmitter { return this._hasOwnerMetadata; } - // Profiling data has been recorded for at least one root. - get hasProfilingData(): boolean { - return ( - this._importedProfilingData !== null || - this._profilingOperationsByRootID.size > 0 - ); - } - - get importedProfilingData(): ImportedProfilingData | null { - return this._importedProfilingData; - } - set importedProfilingData(value: ImportedProfilingData | null): void { - this._importedProfilingData = value; - this._profilingOperationsByRootID = new Map(); - this._profilingScreenshotsByRootID = new Map(); - this._profilingSnapshotsByRootID = new Map(); - this._profilingCache.invalidate(); - - this.emit('importedProfilingData'); - } - - get isProfiling(): boolean { - return this._isProfiling; - } - get numElements(): number { return this._weightAcrossRoots; } - get profilingCache(): ProfilingCache { - return this._profilingCache; - } - - get profilingOperations(): Map> { - return this._profilingOperationsByRootID; - } - - get profilingScreenshots(): Map> { - return this._profilingScreenshotsByRootID; - } - - get profilingSnapshots(): Map> { - return this._profilingSnapshotsByRootID; + get profilerStore(): ProfilerStore { + return this._profilerStore; } get revision(): number { return this._revision; } + get rootIDToRendererID(): Map { + return this._rootIDToRendererID; + } + get roots(): $ReadOnlyArray { return this._roots; } @@ -352,10 +255,6 @@ export default class Store extends EventEmitter { return this._supportsCaptureScreenshots; } - get supportsFileDownloads(): boolean { - return this._supportsFileDownloads; - } - get supportsProfiling(): boolean { return this._supportsProfiling; } @@ -364,19 +263,6 @@ export default class Store extends EventEmitter { return this._supportsReloadAndProfile; } - clearProfilingData(): void { - this._importedProfilingData = null; - this._profilingOperationsByRootID = new Map(); - this._profilingScreenshotsByRootID = new Map(); - this._profilingSnapshotsByRootID = new Map(); - - // Invalidate suspense cache if profiling data is being (re-)recorded. - // Note that we clear now because any existing data is "stale". - this._profilingCache.invalidate(); - - this.emit('isProfiling'); - } - containsElement(id: number): boolean { return this._idToElement.get(id) != null; } @@ -602,24 +488,6 @@ export default class Store extends EventEmitter { return false; } - startProfiling(): void { - this._bridge.send('startProfiling'); - - // 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. - } - // TODO Maybe split this into two methods: expand() and collapse() toggleIsCollapsed(id: number, isCollapsed: boolean): void { let didMutate = false; @@ -702,32 +570,6 @@ export default class Store extends EventEmitter { } } - _captureScreenshot = throttle( - memoize((rootID: number, commitIndex: number) => { - this._bridge.send('captureScreenshot', { commitIndex, rootID }); - }), - THROTTLE_CAPTURE_SCREENSHOT_DURATION - ); - - _takeProfilingSnapshotRecursive = ( - elementID: number, - profilingSnapshots: Map - ) => { - const element = this.getElementByID(elementID); - if (element !== null) { - profilingSnapshots.set(elementID, { - id: elementID, - children: element.children.slice(0), - displayName: element.displayName, - key: element.key, - }); - - element.children.forEach(childID => - this._takeProfilingSnapshotRecursive(childID, profilingSnapshots) - ); - } - }; - _adjustParentTreeWeight = ( parentElement: Element | null, weightDelta: number @@ -768,23 +610,8 @@ export default class Store extends EventEmitter { let haveRootsChanged = false; + // The first two values are always rendererID and rootID const rendererID = operations[0]; - const rootID = operations[1]; - - if (this._isProfiling) { - let profilingOperations = this._profilingOperationsByRootID.get(rootID); - if (profilingOperations == null) { - profilingOperations = [operations]; - this._profilingOperationsByRootID.set(rootID, profilingOperations); - } else { - profilingOperations.push(operations); - } - - if (this._captureScreenshots) { - const commitIndex = profilingOperations.length - 1; - this._captureScreenshot(rootID, commitIndex); - } - } const addedElementIDs: Array = []; // This is a mapping of removed ID -> parent ID: @@ -856,10 +683,6 @@ export default class Store extends EventEmitter { weight: 0, }); - if (this._isProfiling) { - this._profilingSnapshotsByRootID.set(id, new Map()); - } - haveRootsChanged = true; } else { parentID = ((operations[i]: any): number); @@ -955,10 +778,6 @@ export default class Store extends EventEmitter { this._rootIDToRendererID.delete(id); this._rootIDToCapabilities.delete(id); - this._profilingOperationsByRootID.delete(id); - this._profilingScreenshotsByRootID.delete(id); - this._profilingSnapshotsByRootID.delete(id); - haveRootsChanged = true; } else { if (__DEBUG__) { @@ -1040,6 +859,8 @@ export default class Store extends EventEmitter { this._revision++; if (haveRootsChanged) { + const prevSupportsProfiling = this._supportsProfiling; + this._hasOwnerMetadata = false; this._supportsProfiling = false; this._rootIDToCapabilities.forEach( @@ -1054,6 +875,10 @@ export default class Store extends EventEmitter { ); this.emit('roots'); + + if (this._supportsProfiling !== prevSupportsProfiling) { + this.emit('supportsProfiling'); + } } if (__DEBUG__) { @@ -1064,60 +889,12 @@ export default class Store extends EventEmitter { this.emit('mutated', [addedElementIDs, removedElementIDs]); }; - onProfilingStatus = (isProfiling: boolean) => { - if (isProfiling) { - this._importedProfilingData = null; - this._profilingOperationsByRootID = new Map(); - this._profilingScreenshotsByRootID = new Map(); - this._profilingSnapshotsByRootID = new Map(); - this.roots.forEach(rootID => { - const profilingSnapshots = new Map(); - this._profilingSnapshotsByRootID.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._profilingCache.invalidate(); - - this.emit('isProfiling'); - } - }; - - onScreenshotCaptured = ({ - commitIndex, - dataURL, - rootID, - }: {| - commitIndex: number, - dataURL: string, - rootID: number, - |}) => { - let profilingScreenshotsForRootByCommitIndex = this._profilingScreenshotsByRootID.get( - rootID - ); - if (!profilingScreenshotsForRootByCommitIndex) { - profilingScreenshotsForRootByCommitIndex = new Map(); - this._profilingScreenshotsByRootID.set( - rootID, - profilingScreenshotsForRootByCommitIndex - ); - } - profilingScreenshotsForRootByCommitIndex.set(commitIndex, dataURL); - }; - onBridgeShutdown = () => { if (__DEBUG__) { debug('onBridgeShutdown', 'unsubscribing from Bridge'); } this._bridge.removeListener('operations', this.onBridgeOperations); - this._bridge.removeListener('profilingStatus', this.onProfilingStatus); this._bridge.removeListener('shutdown', this.onBridgeShutdown); }; } diff --git a/src/devtools/views/Components/ComponentFiltersModal.js b/src/devtools/views/Components/ComponentFiltersModal.js index 45e00b7ff4..7797dcbf24 100644 --- a/src/devtools/views/Components/ComponentFiltersModal.js +++ b/src/devtools/views/Components/ComponentFiltersModal.js @@ -41,6 +41,7 @@ import type { export default function ComponentFiltersModalWrapper(_: {||}) { const store = useContext(StoreContext); + const { profilerStore } = store; const { isModalShowing, setIsModalShowing } = useContext( ComponentFiltersModalContext @@ -50,13 +51,13 @@ export default function ComponentFiltersModalWrapper(_: {||}) { // If necessary, we could support this- but it doesn't seem like a necessary use case. const isProfilingSubscription = useMemo( () => ({ - getCurrentValue: () => store.isProfiling, + getCurrentValue: () => profilerStore.isProfiling, subscribe: (callback: Function) => { - store.addListener('isProfiling', callback); - return () => store.removeListener('isProfiling', callback); + profilerStore.addListener('isProfiling', callback); + return () => profilerStore.removeListener('isProfiling', callback); }, }), - [store] + [profilerStore] ); const isProfiling = useSubscription(isProfilingSubscription); if (isProfiling && isModalShowing) { diff --git a/src/devtools/views/Components/Components.js b/src/devtools/views/Components/Components.js index dd4b775431..3ed317a101 100644 --- a/src/devtools/views/Components/Components.js +++ b/src/devtools/views/Components/Components.js @@ -4,6 +4,7 @@ import React, { Suspense } from 'react'; import Tree from './Tree'; import SelectedElement from './SelectedElement'; import { InspectedElementContextController } from './InspectedElementContext'; +import { OwnersListContextController } from './OwnersListContext'; import portaledContent from '../portaledContent'; import { ModalDialog } from '../ModalDialog'; @@ -12,19 +13,21 @@ import styles from './Components.css'; function Components(_: {||}) { // TODO Flex wrappers below should be user resizable. return ( -
-
- -
-
- - }> - - - -
- -
+ + +
+
+ +
+
+ }> + + +
+ +
+
+
); } diff --git a/src/devtools/views/Components/Element.css b/src/devtools/views/Components/Element.css index da7a214de5..5833d462c8 100644 --- a/src/devtools/views/Components/Element.css +++ b/src/devtools/views/Components/Element.css @@ -28,6 +28,9 @@ /* Invert colors */ --color-component-name: var(--color-component-name-inverted); + --color-component-badge-background: var( + --color-component-badge-background-inverted + ); --color-jsx-arrow-brackets: var(--color-jsx-arrow-brackets-inverted); --color-attribute-name: var(--color-background-hover); --color-attribute-value: var(--color-component-name-inverted); @@ -73,3 +76,13 @@ height: 1rem; color: var(--color-expand-collapse-toggle); } + +.Badge { + background-color: var(--color-component-badge-background); + padding: 0.125rem 0.25rem; + line-height: normal; + border-radius: 0.125rem; + margin-left: 0.25rem; + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-small); +} diff --git a/src/devtools/views/Components/Element.js b/src/devtools/views/Components/Element.js index 322726784d..0f3bb34a7a 100644 --- a/src/devtools/views/Components/Element.js +++ b/src/devtools/views/Components/Element.js @@ -9,7 +9,12 @@ import React, { useRef, useState, } from 'react'; -import { ElementTypeClass, ElementTypeFunction } from 'src/types'; +import { + ElementTypeClass, + ElementTypeFunction, + ElementTypeMemo, + ElementTypeForwardRef, +} from 'src/types'; import Store from 'src/devtools/store'; import ButtonIcon from '../ButtonIcon'; import { createRegExp } from '../utils'; @@ -29,7 +34,7 @@ type Props = { export default function ElementView({ data, index, style }: Props) { const store = useContext(StoreContext); - const { ownerFlatTree, ownerStack, selectedElementID } = useContext( + const { ownerFlatTree, ownerID, selectedElementID } = useContext( TreeStateContext ); const dispatch = useContext(TreeDispatcherContext); @@ -134,6 +139,7 @@ export default function ElementView({ data, index, style }: Props) { const showDollarR = isSelected && (type === ElementTypeClass || type === ElementTypeFunction); + const showBadge = type === ElementTypeMemo || type === ElementTypeForwardRef; let className = styles.Element; if (isSelected) { @@ -168,7 +174,7 @@ export default function ElementView({ data, index, style }: Props) { }} > - {ownerStack.length === 0 ? ( + {ownerID === null ? ( ) : null} @@ -182,6 +188,11 @@ export default function ElementView({ data, index, style }: Props) { {showDollarR &&  == $r} + {showBadge && ( + + {type === ElementTypeMemo ? 'Memo' : 'ForwardRef'} + + )} ); } diff --git a/src/devtools/views/Components/EventsTree.css b/src/devtools/views/Components/EventsTree.css new file mode 100644 index 0000000000..7506ba430d --- /dev/null +++ b/src/devtools/views/Components/EventsTree.css @@ -0,0 +1,27 @@ +.EventsTree { + padding: 0.25rem; + border-top: 1px solid var(--color-border); +} +.EventsTree:first-of-type { + border-top: none; +} + +.HeaderRow { + display: flex; + align-items: center; +} + +.Header { + flex: 1 1; + font-family: var(--font-family-sans); +} + +.NameValueRow { + display: flex; +} + +.Empty { + color: var(--color-dimmer); + font-style: italic; + padding-left: 0.75rem; +} diff --git a/src/devtools/views/Components/EventsTree.js b/src/devtools/views/Components/EventsTree.js new file mode 100644 index 0000000000..3c8635e627 --- /dev/null +++ b/src/devtools/views/Components/EventsTree.js @@ -0,0 +1,88 @@ +import { copy } from 'clipboard-js'; +import React, { useCallback, useState } from 'react'; +import styles from './EventsTree.css'; +import Button from '../Button'; +import ButtonIcon from '../ButtonIcon'; +import KeyValue from './KeyValue'; +import ExpandCollapseToggle from './ExpandCollapseToggle'; +import { serializeDataForCopy } from '../utils'; + +type Props = {| + events: Object, +|}; + +function EventsTreeView({ events }: Props) { + const handleCopy = useCallback(() => copy(serializeDataForCopy(events)), [ + events, + ]); + + return ( +
+
+
events
+ { + + } +
+ +
+ ); +} + +function InnerEventsTreeView({ events }: Props) { + return events.map((event, index) => ( + + )); +} + +type EventComponentViewProps = {| + displayName: string, + props: null | Object, +|}; + +function EventComponentView({ displayName, props }: EventComponentViewProps) { + const [isOpen, setIsOpen] = useState(false); + let eventComponentProps = null; + // eslint-disable-next-line no-unused-vars + let children; + + if (props !== null) { + // We don't want children, so extract it out + ({ children, ...eventComponentProps } = props); + } + + return ( +
+
+ + {}} className={styles.Name}> + {displayName} + +
+ +
+ ); +} + +// $FlowFixMe +export default React.memo(EventsTreeView); diff --git a/src/devtools/views/Components/InspectedElementContext.js b/src/devtools/views/Components/InspectedElementContext.js index 0a87518f05..75b49f4898 100644 --- a/src/devtools/views/Components/InspectedElementContext.js +++ b/src/devtools/views/Components/InspectedElementContext.js @@ -83,13 +83,22 @@ function InspectedElementContextController({ children }: Props) { // This effect handler invalidates the suspense cache and schedules rendering updates with React. useEffect(() => { - const onInspectedElement = (inspectedElement: InspectedElement | null) => { - if (inspectedElement !== null) { + const onInspectedElement = ( + inspectedElement: InspectedElement | number | null + ) => { + // A null value means that the element no longer exists in the backend. + // If it's the same element that's currently selected, that selection will be removed once the Store updates. + // If it's not- then we can just ignore it anyway. + // Either way there is nothing we need to do in this case. + // A numeric value indicates that the element hasn't changed since we last requested its data, + // in which case we don't need to invalidate the cache and re-render anything in the DevTools. + if (inspectedElement !== null && typeof inspectedElement === 'object') { const id = inspectedElement.id; inspectedElement = (({ ...inspectedElement, context: hydrateHelper(inspectedElement.context), + events: hydrateHelper(inspectedElement.events), hooks: hydrateHelper(inspectedElement.hooks), props: hydrateHelper(inspectedElement.props), state: hydrateHelper(inspectedElement.state), @@ -140,13 +149,19 @@ function InspectedElementContextController({ children }: Props) { // Update the $r variable. bridge.send('selectElement', { id: selectedElementID, rendererID }); - const onInspectedElement = (inspectedElement: InspectedElement | null) => { - if ( - inspectedElement !== null && - inspectedElement.id === selectedElementID - ) { + const onInspectedElement = ( + inspectedElement: InspectedElement | number | null + ) => { + if (inspectedElement !== null) { // If this is the element we requested, wait a little bit and then ask for an update. - timeoutID = setTimeout(sendRequest, 1000); + if (inspectedElement === selectedElementID) { + timeoutID = setTimeout(sendRequest, 1000); + } else if ( + typeof inspectedElement === 'object' && + inspectedElement.id === selectedElementID + ) { + timeoutID = setTimeout(sendRequest, 1000); + } } }; diff --git a/src/devtools/views/Components/OwnersListContext.js b/src/devtools/views/Components/OwnersListContext.js new file mode 100644 index 0000000000..3720f3b2cc --- /dev/null +++ b/src/devtools/views/Components/OwnersListContext.js @@ -0,0 +1,109 @@ +// @flow + +import React, { + createContext, + useCallback, + useContext, + useEffect, +} from 'react'; +import { createResource } from '../../cache'; +import { BridgeContext, StoreContext } from '../context'; +import { TreeStateContext } from './TreeContext'; + +import type { + Element, + Owner, + OwnersList, +} from 'src/devtools/views/Components/types'; +import type { Resource, Thenable } from '../../cache'; + +type Context = (id: number) => Array | null; + +const OwnersListContext = createContext(((null: any): Context)); +OwnersListContext.displayName = 'OwnersListContext'; + +type ResolveFn = (ownersList: Array | null) => void; +type InProgressRequest = {| + promise: Thenable>, + resolveFn: ResolveFn, +|}; + +const inProgressRequests: WeakMap = new WeakMap(); +const resource: Resource> = 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 OwnersListContextController({ children }: Props) { + const bridge = useContext(BridgeContext); + const store = useContext(StoreContext); + const { ownerID } = useContext(TreeStateContext); + + const read = useCallback( + (id: number) => { + const element = store.getElementByID(id); + if (element !== null) { + return resource.read(element); + } else { + return null; + } + }, + [store] + ); + + useEffect(() => { + const onOwnersList = (ownersList: OwnersList) => { + const id = ownersList.id; + + const element = store.getElementByID(id); + if (element !== null) { + const request = inProgressRequests.get(element); + if (request != null) { + inProgressRequests.delete(element); + request.resolveFn(ownersList.owners); + } + } + }; + + bridge.addListener('ownersList', onOwnersList); + return () => bridge.removeListener('ownersList', onOwnersList); + }, [bridge, store]); + + // This effect requests an updated owners list any time the selected owner changes + useEffect(() => { + if (ownerID !== null) { + const rendererID = store.getRendererIDForElement(ownerID); + + bridge.send('getOwnersList', { id: ownerID, rendererID }); + } + + return () => {}; + }, [bridge, ownerID, store]); + + return ( + + {children} + + ); +} + +export { OwnersListContext, OwnersListContextController }; diff --git a/src/devtools/views/Components/OwnersStack.css b/src/devtools/views/Components/OwnersStack.css index 1aba50714a..48c9db0fc4 100644 --- a/src/devtools/views/Components/OwnersStack.css +++ b/src/devtools/views/Components/OwnersStack.css @@ -91,3 +91,8 @@ font-family: var(--font-family-monospace); font-size: var(--font-size-monospace-normal); } + +.NotInStore, +.NotInStore:hover { + color: var(--color-dimmest); +} diff --git a/src/devtools/views/Components/OwnersStack.js b/src/devtools/views/Components/OwnersStack.js index 9635045d33..3883a14480 100644 --- a/src/devtools/views/Components/OwnersStack.js +++ b/src/devtools/views/Components/OwnersStack.js @@ -4,6 +4,7 @@ import React, { useCallback, useContext, useLayoutEffect, + useReducer, useRef, useState, } from 'react'; @@ -12,22 +13,113 @@ import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button'; import Button from '../Button'; import ButtonIcon from '../ButtonIcon'; import Toggle from '../Toggle'; +import { OwnersListContext } from './OwnersListContext'; import { TreeDispatcherContext, TreeStateContext } from './TreeContext'; -import { StoreContext } from '../context'; import { useIsOverflowing } from '../hooks'; +import { StoreContext } from '../context'; -import type { Element } from './types'; +import type { Owner } from './types'; import styles from './OwnersStack.css'; +type SelectOwner = (owner: Owner | null) => void; + +type ACTION_UPDATE_OWNER_ID = {| + type: 'UPDATE_OWNER_ID', + ownerID: number | null, + owners: Array, +|}; +type ACTION_UPDATE_SELECTED_INDEX = {| + type: 'UPDATE_SELECTED_INDEX', + selectedIndex: number, +|}; + +type Action = ACTION_UPDATE_OWNER_ID | ACTION_UPDATE_SELECTED_INDEX; + +type State = {| + ownerID: number | null, + owners: Array, + selectedIndex: number, +|}; + +function dialogReducer(state, action) { + switch (action.type) { + case 'UPDATE_OWNER_ID': + const selectedIndex = action.owners.findIndex( + owner => owner.id === action.ownerID + ); + return { + ownerID: action.ownerID, + owners: action.owners, + selectedIndex, + }; + case 'UPDATE_SELECTED_INDEX': + return { + ...state, + selectedIndex: action.selectedIndex, + }; + default: + throw new Error(`Invalid action "${action.type}"`); + } +} + export default function OwnerStack() { - const { ownerStack, ownerStackIndex } = useContext(TreeStateContext); - const dispatch = useContext(TreeDispatcherContext); + const read = useContext(OwnersListContext); + const { ownerID } = useContext(TreeStateContext); + const treeDispatch = useContext(TreeDispatcherContext); + + const [state, dispatch] = useReducer(dialogReducer, { + ownerID: null, + owners: [], + selectedIndex: 0, + }); + + // When an owner is selected, we either need to update the selected index, or we need to fetch a new list of owners. + // We use a reducer here so that we can avoid fetching a new list unless the owner ID has actually changed. + if (ownerID === null) { + dispatch({ + type: 'UPDATE_OWNER_ID', + ownerID: null, + owners: [], + }); + } else if (ownerID !== state.ownerID) { + const isInStore = + state.owners.findIndex(owner => owner.id === ownerID) >= 0; + dispatch({ + type: 'UPDATE_OWNER_ID', + ownerID, + owners: isInStore ? state.owners : read(ownerID) || [], + }); + } + + const { owners, selectedIndex } = state; + + const selectOwner = useCallback( + (owner: Owner | null) => { + if (owner !== null) { + const index = owners.indexOf(owner); + dispatch({ + type: 'UPDATE_SELECTED_INDEX', + selectedIndex: index >= 0 ? index : 0, + }); + treeDispatch({ type: 'SELECT_OWNER', payload: owner.id }); + } else { + dispatch({ + type: 'UPDATE_SELECTED_INDEX', + selectedIndex: 0, + }); + treeDispatch({ type: 'RESET_OWNER_STACK' }); + } + }, + [owners, treeDispatch] + ); const [elementsTotalWidth, setElementsTotalWidth] = useState(0); const elementsBarRef = useRef(null); const isOverflowing = useIsOverflowing(elementsBarRef, elementsTotalWidth); + const selectedOwner = owners[selectedIndex]; + useLayoutEffect(() => { // If we're already overflowing, then we don't need to re-measure items. // That's because once the owners stack is open, it can only get larger (by driling in). @@ -37,7 +129,7 @@ export default function OwnerStack() { } let elementsTotalWidth = 0; - for (let i = 0; i < ownerStack.length; i++) { + for (let i = 0; i < owners.length; i++) { const element = elementsBarRef.current.children[i]; const computedStyle = getComputedStyle(element); @@ -48,7 +140,7 @@ export default function OwnerStack() { } setElementsTotalWidth(elementsTotalWidth); - }, [elementsBarRef, isOverflowing, ownerStack.length]); + }, [elementsBarRef, isOverflowing, owners.length]); return (
@@ -56,28 +148,38 @@ export default function OwnerStack() { {isOverflowing && ( - + {selectedOwner != null && ( + + )} )} {!isOverflowing && - ownerStack.map((id, index) => ( - + owners.map((owner, index) => ( + ))}
diff --git a/src/devtools/views/Components/SelectedElement.js b/src/devtools/views/Components/SelectedElement.js index 90da16ee48..aa831c11ad 100644 --- a/src/devtools/views/Components/SelectedElement.js +++ b/src/devtools/views/Components/SelectedElement.js @@ -6,6 +6,7 @@ import { BridgeContext, StoreContext } from '../context'; import Button from '../Button'; import ButtonIcon from '../ButtonIcon'; import HooksTree from './HooksTree'; +import EventsTree from './EventsTree'; import { ModalDialogContext } from '../ModalDialog'; import InspectedElementTree from './InspectedElementTree'; import { InspectedElementContext } from './InspectedElementContext'; @@ -221,13 +222,14 @@ function InspectedElementView({ canEditHooks, canToggleSuspense, context, + events, hooks, owners, props, state, } = inspectedElement; - const { ownerStack } = useContext(TreeStateContext); + const { ownerID } = useContext(TreeStateContext); const bridge = useContext(BridgeContext); const store = useContext(StoreContext); @@ -297,14 +299,15 @@ function InspectedElementView({ data={context} overrideValueFn={overrideContextFn} /> + {events !== null && events.length > 0 && } - {ownerStack.length === 0 && owners !== null && owners.length > 0 && ( + {ownerID === null && owners !== null && owners.length > 0 && (
rendered by
{owners.map(owner => ( diff --git a/src/devtools/views/Components/ToggleComponentFiltersModalButton.js b/src/devtools/views/Components/ToggleComponentFiltersModalButton.js index 189454d349..e1d237f33a 100644 --- a/src/devtools/views/Components/ToggleComponentFiltersModalButton.js +++ b/src/devtools/views/Components/ToggleComponentFiltersModalButton.js @@ -14,6 +14,7 @@ import type { ComponentFilter } from 'src/types'; export default function ToggleComponentFiltersModalButton() { const store = useContext(StoreContext); + const { profilerStore } = store; const { isModalShowing, setIsModalShowing } = useContext( ComponentFiltersModalContext @@ -23,13 +24,13 @@ export default function ToggleComponentFiltersModalButton() { // If necessary, we could support this- but it doesn't seem like a necessary use case. const isProfilingSubscription = useMemo( () => ({ - getCurrentValue: () => store.isProfiling, + getCurrentValue: () => profilerStore.isProfiling, subscribe: (callback: Function) => { - store.addListener('isProfiling', callback); - return () => store.removeListener('isProfiling', callback); + profilerStore.addListener('isProfiling', callback); + return () => profilerStore.removeListener('isProfiling', callback); }, }), - [store] + [profilerStore] ); const isProfiling = useSubscription(isProfilingSubscription); diff --git a/src/devtools/views/Components/Tree.css b/src/devtools/views/Components/Tree.css index 3ce44f0828..3efc1bf863 100644 --- a/src/devtools/views/Components/Tree.css +++ b/src/devtools/views/Components/Tree.css @@ -37,3 +37,14 @@ margin: 0 0.5rem; background-color: var(--color-border); } + +.Loading { + height: 100%; + padding-left: 0.5rem; + display: flex; + align-items: center; + flex: 1; + justify-content: flex-start; + font-size: var(--font-size-sans-large); + color: var(--color-dim); +} diff --git a/src/devtools/views/Components/Tree.js b/src/devtools/views/Components/Tree.js index 8c3c4031f7..4687c79939 100644 --- a/src/devtools/views/Components/Tree.js +++ b/src/devtools/views/Components/Tree.js @@ -1,6 +1,7 @@ // @flow import React, { + Suspense, useState, useCallback, useContext, @@ -38,7 +39,7 @@ export default function Tree(props: Props) { const dispatch = useContext(TreeDispatcherContext); const { numElements, - ownerStack, + ownerID, searchIndex, searchResults, selectedElementID, @@ -277,7 +278,9 @@ export default function Tree(props: Props) {
- {ownerStack.length > 0 ? : } + }> + {ownerID !== null ? : } +
@@ -317,7 +320,7 @@ export default function Tree(props: Props) { } function InnerElementType({ style, ...rest }) { - const { ownerStack } = useContext(TreeStateContext); + const { ownerID } = useContext(TreeStateContext); // The list may need to scroll horizontally due to deeply nested elements. // We don't know the maximum scroll width up front, because we're windowing. @@ -346,10 +349,9 @@ function InnerElementType({ style, ...rest }) { // We shouldn't retain this width across different conceptual trees though, // so when the user opens the "owners tree" view, we should discard the previous width. - const hasOwnerStack = ownerStack.length > 0; - const [prevHasOwnerStack, setPrevHasOwnerStack] = useState(hasOwnerStack); - if (hasOwnerStack !== prevHasOwnerStack) { - setPrevHasOwnerStack(hasOwnerStack); + const [prevOwnerID, setPrevOwnerID] = useState(ownerID); + if (ownerID !== prevOwnerID) { + setPrevOwnerID(ownerID); setMinWidth(null); } @@ -371,3 +373,7 @@ function InnerElementType({ style, ...rest }) { /> ); } + +function Loading() { + return
Loading...
; +} diff --git a/src/devtools/views/Components/TreeContext.js b/src/devtools/views/Components/TreeContext.js index 002f6b619e..90c514b3e7 100644 --- a/src/devtools/views/Components/TreeContext.js +++ b/src/devtools/views/Components/TreeContext.js @@ -38,7 +38,7 @@ import Store from '../../store'; import type { Element } from './types'; -type StateContext = {| +export type StateContext = {| // Tree numElements: number, selectedElementID: number | null, @@ -50,9 +50,8 @@ type StateContext = {| searchText: string, // Owners + ownerID: number | null, ownerFlatTree: Array | null, - ownerStack: Array, - ownerStackIndex: number | null, // Inspection element panel inspectedElementID: number | null, @@ -118,7 +117,7 @@ type Action = | ACTION_SET_SEARCH_TEXT | ACTION_UPDATE_INSPECTED_ELEMENT_ID; -type DispatcherContext = (action: Action) => void; +export type DispatcherContext = (action: Action) => void; const TreeStateContext = createContext( ((null: any): StateContext) @@ -142,8 +141,7 @@ type State = {| searchText: string, // Owners - ownerStack: Array, - ownerStackIndex: number | null, + ownerID: number | null, ownerFlatTree: Array | null, // Inspection element panel @@ -151,17 +149,12 @@ type State = {| |}; function reduceTreeState(store: Store, state: State, action: Action): State { - let { - numElements, - ownerStack, - selectedElementIndex, - selectedElementID, - } = state; + let { numElements, ownerID, selectedElementIndex, selectedElementID } = state; let lookupIDForIndex = true; // Base tree should ignore selected element changes when the owner's tree is active. - if (ownerStack.length === 0) { + if (ownerID === null) { switch (action.type) { case 'HANDLE_STORE_MUTATION': numElements = store.numElements; @@ -276,7 +269,7 @@ function reduceTreeState(store: Store, state: State, action: Action): State { function reduceSearchState(store: Store, state: State, action: Action): State { let { - ownerStack, + ownerID, searchIndex, searchResults, searchText, @@ -295,7 +288,7 @@ function reduceSearchState(store: Store, state: State, action: Action): State { let didRequestSearch = false; // Search isn't supported when the owner's tree is active. - if (ownerStack.length === 0) { + if (ownerID === null) { switch (action.type) { case 'GO_TO_NEXT_SEARCH_RESULT': if (numPrevSearchResults > 0) { @@ -442,9 +435,8 @@ function reduceOwnersState(store: Store, state: State, action: Action): State { numElements, selectedElementID, selectedElementIndex, + ownerID, ownerFlatTree, - ownerStack, - ownerStackIndex, searchIndex, searchResults, searchText, @@ -454,30 +446,20 @@ function reduceOwnersState(store: Store, state: State, action: Action): State { switch (action.type) { case 'HANDLE_STORE_MUTATION': - if (ownerStack.length > 0) { - let indexOfRemovedItem = -1; - for (let i = 0; i < ownerStack.length; i++) { - if (store.getElementByID(ownerStack[i]) === null) { - indexOfRemovedItem = i; - break; + if (ownerID !== null) { + if (!store.containsElement(ownerID)) { + ownerID = null; + ownerFlatTree = null; + selectedElementID = null; + } else { + ownerFlatTree = store.getOwnersListForElement(ownerID); + if (selectedElementID !== null) { + // Mutation might have caused the index of this ID to shift. + selectedElementIndex = ownerFlatTree.findIndex( + element => element.id === selectedElementID + ); } } - - if (indexOfRemovedItem >= 0) { - ownerStack = ownerStack.slice(0, indexOfRemovedItem); - if (ownerStack.length === 0) { - ownerFlatTree = null; - ownerStackIndex = null; - } else { - ownerStackIndex = ownerStack.length - 1; - } - } - if (selectedElementID !== null && ownerFlatTree !== null) { - // Mutation might have caused the index of this ID to shift. - selectedElementIndex = ownerFlatTree.findIndex( - element => element.id === selectedElementID - ); - } } else { if (selectedElementID !== null) { // Mutation might have caused the index of this ID to shift. @@ -491,13 +473,12 @@ function reduceOwnersState(store: Store, state: State, action: Action): State { } break; case 'RESET_OWNER_STACK': - ownerStack = []; - ownerStackIndex = null; + ownerID = null; + ownerFlatTree = null; selectedElementIndex = selectedElementID !== null ? store.getIndexOfElementID(selectedElementID) : null; - ownerFlatTree = null; break; case 'SELECT_ELEMENT_AT_INDEX': if (ownerFlatTree !== null) { @@ -533,33 +514,12 @@ function reduceOwnersState(store: Store, state: State, action: Action): State { // If the Store doesn't have any owners metadata, don't drill into an empty stack. // This is a confusing user experience. if (store.hasOwnerMetadata) { - const id = (action: ACTION_SELECT_OWNER).payload; - ownerStackIndex = ownerStack.indexOf(id); + ownerID = (action: ACTION_SELECT_OWNER).payload; + ownerFlatTree = store.getOwnersListForElement(ownerID); // Always force reset selection to be the top of the new owner tree. selectedElementIndex = 0; prevSelectedElementIndex = null; - - // If this owner is already in the current stack, just select it. - // Otherwise, create a new stack. - if (ownerStackIndex < 0) { - // Add this new owner, and fill in the owners above it as well. - ownerStack = []; - let currentOwnerID = id; - while (currentOwnerID !== 0) { - ownerStack.unshift(currentOwnerID); - currentOwnerID = ((store.getElementByID( - currentOwnerID - ): any): Element).ownerID; - } - ownerStackIndex = ownerStack.length - 1; - - if (searchText !== '') { - searchIndex = null; - searchResults = []; - searchText = ''; - } - } } break; default: @@ -569,17 +529,12 @@ function reduceOwnersState(store: Store, state: State, action: Action): State { // Changes in the selected owner require re-calculating the owners tree. if ( - ownerStackIndex !== state.ownerStackIndex || - ownerStack !== state.ownerStack || + ownerFlatTree !== state.ownerFlatTree || action.type === 'HANDLE_STORE_MUTATION' ) { - if (ownerStackIndex === null) { - ownerFlatTree = null; + if (ownerFlatTree === null) { numElements = store.numElements; } else { - ownerFlatTree = store.getOwnersListForElement( - ownerStack[ownerStackIndex] - ); numElements = ownerFlatTree.length; } } @@ -588,9 +543,10 @@ function reduceOwnersState(store: Store, state: State, action: Action): State { if (selectedElementIndex !== prevSelectedElementIndex) { if (selectedElementIndex === null) { selectedElementID = null; - } else if (ownerFlatTree !== null) { - selectedElementID = - ownerFlatTree[((selectedElementIndex: any): number)].id; + } else { + if (ownerFlatTree !== null) { + selectedElementID = ownerFlatTree[selectedElementIndex].id; + } } } @@ -605,8 +561,7 @@ function reduceOwnersState(store: Store, state: State, action: Action): State { searchResults, searchText, - ownerStack, - ownerStackIndex, + ownerID, ownerFlatTree, }; } @@ -619,20 +574,37 @@ function reduceSuspenseState( const { type } = action; switch (type) { case 'UPDATE_INSPECTED_ELEMENT_ID': - return { - ...state, - inspectedElementID: state.selectedElementID, - }; + if (state.inspectedElementID !== state.selectedElementID) { + return { + ...state, + inspectedElementID: state.selectedElementID, + }; + } + break; default: - // React can bailout of no-op updates. - return state; + break; } + + // React can bailout of no-op updates. + return state; } -type Props = {| children: React$Node |}; +type Props = {| + children: React$Node, + + // Used for automated testing + defaultOwnerID?: ?number, + defaultSelectedElementID?: ?number, + defaultSelectedElementIndex?: ?number, +|}; // TODO Remove TreeContextController wrapper element once global ConsearchText.write API exists. -function TreeContextController({ children }: Props) { +function TreeContextController({ + children, + defaultOwnerID, + defaultSelectedElementID, + defaultSelectedElementIndex, +}: Props) { const bridge = useContext(BridgeContext); const store = useContext(StoreContext); @@ -687,8 +659,10 @@ function TreeContextController({ children }: Props) { const [state, dispatch] = useReducer(reducer, { // Tree numElements: store.numElements, - selectedElementIndex: null, - selectedElementID: null, + selectedElementID: + defaultSelectedElementID == null ? null : defaultSelectedElementID, + selectedElementIndex: + defaultSelectedElementIndex == null ? null : defaultSelectedElementIndex, // Search searchIndex: null, @@ -696,8 +670,7 @@ function TreeContextController({ children }: Props) { searchText: '', // Owners - ownerStack: [], - ownerStackIndex: null, + ownerID: defaultOwnerID == null ? null : defaultOwnerID, ownerFlatTree: null, // Inspection element panel diff --git a/src/devtools/views/Components/types.js b/src/devtools/views/Components/types.js index 0849ec019a..5613e9c205 100644 --- a/src/devtools/views/Components/types.js +++ b/src/devtools/views/Components/types.js @@ -31,10 +31,15 @@ export type Element = {| |}; export type Owner = {| - displayName: string, + displayName: string | null, id: number, |}; +export type OwnersList = {| + id: number, + owners: Array | null, +|}; + export type InspectedElement = {| id: number, @@ -54,6 +59,7 @@ export type InspectedElement = {| // Inspectable properties. context: Object | null, + events: Object | null, hooks: Object | null, props: Object | null, state: Object | null, diff --git a/src/devtools/views/DevTools.js b/src/devtools/views/DevTools.js index 1efc67a707..c02eef4fe1 100644 --- a/src/devtools/views/DevTools.js +++ b/src/devtools/views/DevTools.js @@ -5,7 +5,7 @@ import '@reach/menu-button/styles.css'; import '@reach/tooltip/styles.css'; -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import Store from '../store'; import { BridgeContext, StoreContext } from './context'; import Components from './Components/Components'; @@ -71,8 +71,7 @@ const settingsTab = { title: 'React Settings', }; -const tabsWithProfiler = [componentsTab, profilerTab, settingsTab]; -const tabsWithoutProfiler = [componentsTab, settingsTab]; +const tabs = [componentsTab, profilerTab, settingsTab]; export default function DevTools({ bridge, @@ -92,28 +91,6 @@ export default function DevTools({ setTab(overrideTab); } - const [supportsProfiling, setSupportsProfiling] = useState( - store.supportsProfiling - ); - - // Show/hide the "Profiler" button depending on if profiling is supported. - useEffect(() => { - if (supportsProfiling !== store.supportsProfiling) { - setSupportsProfiling(store.supportsProfiling); - } - - const handleRoots = () => { - if (supportsProfiling !== store.supportsProfiling) { - setSupportsProfiling(store.supportsProfiling); - } - }; - - store.addListener('roots', handleRoots); - return () => { - store.removeListener('roots', handleRoots); - }; - }, [store, supportsProfiling]); - return ( @@ -140,11 +117,7 @@ export default function DevTools({ id="DevTools" selectTab={setTab} size="large" - tabs={ - supportsProfiling - ? tabsWithProfiler - : tabsWithoutProfiler - } + tabs={tabs} />
)} @@ -158,10 +131,7 @@ export default function DevTools({ className={styles.TabContent} hidden={tab !== 'profiler'} > - +
) => mixed, onDoubleClick?: (event: SyntheticMouseEvent<*>) => mixed, placeLabelAboveNode?: boolean, + textStyle?: Object, width: number, x: number, y: number, @@ -27,6 +28,7 @@ export default function ChartNode({ label, onClick, onDoubleClick, + textStyle, width, x, y, @@ -57,7 +59,9 @@ export default function ChartNode({ }} y={height < textHeight ? -textHeight : 0} > -
{label}
+
+ {label} +
)} diff --git a/src/devtools/views/Profiler/ClearProfilingDataButton.js b/src/devtools/views/Profiler/ClearProfilingDataButton.js index a8be6b0f6c..d3223eb8de 100644 --- a/src/devtools/views/Profiler/ClearProfilingDataButton.js +++ b/src/devtools/views/Profiler/ClearProfilingDataButton.js @@ -8,13 +8,14 @@ import { StoreContext } from '../context'; export default function ClearProfilingDataButton() { const store = useContext(StoreContext); - const { isProfiling } = useContext(ProfilerContext); + const { didRecordCommits, isProfiling } = useContext(ProfilerContext); + const { profilerStore } = store; - const clear = useCallback(() => store.clearProfilingData(), [store]); + const clear = useCallback(() => profilerStore.clear(), [profilerStore]); return ( - {store.supportsFileDownloads && ( - - )} + ); } diff --git a/src/devtools/views/Profiler/RankedChartBuilder.js b/src/devtools/views/Profiler/RankedChartBuilder.js index f11c97200f..67b38fccbf 100644 --- a/src/devtools/views/Profiler/RankedChartBuilder.js +++ b/src/devtools/views/Profiler/RankedChartBuilder.js @@ -1,8 +1,10 @@ // @flow -import { calculateSelfDuration, formatDuration } from './utils'; +import { ElementTypeForwardRef, ElementTypeMemo } from 'src/types'; +import { formatDuration } from './utils'; +import ProfilerStore from 'src/devtools/ProfilerStore'; -import type { CommitDetailsFrontend, CommitTreeFrontend } from './types'; +import type { CommitTree } from './types'; export type ChartNode = {| id: number, @@ -19,15 +21,19 @@ export type ChartData = {| const cachedChartData: Map = new Map(); export function getChartData({ - commitDetails, commitIndex, commitTree, + profilerStore, + rootID, }: {| - commitDetails: CommitDetailsFrontend, commitIndex: number, - commitTree: CommitTreeFrontend, + commitTree: CommitTree, + profilerStore: ProfilerStore, + rootID: number, |}): ChartData { - const { actualDurations, rootID } = commitDetails; + const commitDatum = profilerStore.getCommitData(rootID, commitIndex); + + const { fiberActualDurations, fiberSelfDurations } = commitDatum; const { nodes } = commitTree; const key = `${rootID}-${commitIndex}`; @@ -38,23 +44,35 @@ export function getChartData({ let maxSelfDuration = 0; const chartNodes: Array = []; - actualDurations.forEach((actualDuration, id) => { + fiberActualDurations.forEach((actualDuration, id) => { const node = nodes.get(id); if (node == null) { throw Error(`Could not find node with id "${id}" in commit tree`); } + const { displayName, key, parentID, type } = node; + // Don't show the root node in this chart. - if (node.parentID === 0) { + if (parentID === 0) { return; } - const selfDuration = calculateSelfDuration(id, commitTree, commitDetails); + const selfDuration = fiberSelfDurations.get(id) || 0; maxSelfDuration = Math.max(maxSelfDuration, selfDuration); - const name = node.displayName || 'Unknown'; - const maybeKey = node.key !== null ? ` key="${node.key}"` : ''; - const label = `${name}${maybeKey} (${formatDuration(selfDuration)}ms)`; + const name = displayName || 'Anonymous'; + const maybeKey = key !== null ? ` key="${key}"` : ''; + + let maybeBadge = ''; + if (type === ElementTypeForwardRef) { + maybeBadge = ' (ForwardRef)'; + } else if (type === ElementTypeMemo) { + maybeBadge = ' (Memo)'; + } + + const label = `${name}${maybeBadge}${maybeKey} (${formatDuration( + selfDuration + )}ms)`; chartNodes.push({ id, label, diff --git a/src/devtools/views/Profiler/RecordToggle.css b/src/devtools/views/Profiler/RecordToggle.css index 7dc0c6e365..467bca27e3 100644 --- a/src/devtools/views/Profiler/RecordToggle.css +++ b/src/devtools/views/Profiler/RecordToggle.css @@ -23,3 +23,16 @@ .InactiveRecordToggle:active { color: var(--color-record-hover); } + +.DisabledRecordToggle { + color: var(--color-button-disabled); +} +.DisabledRecordToggle:hover { + color: var(--color-button-disabled); +} +.DisabledRecordToggle:focus { + color: var(--color-button-disabled); +} +.DisabledRecordToggle:active { + color: var(--color-button-disabled); +} diff --git a/src/devtools/views/Profiler/RecordToggle.js b/src/devtools/views/Profiler/RecordToggle.js index 94074002ca..a3d494c716 100644 --- a/src/devtools/views/Profiler/RecordToggle.js +++ b/src/devtools/views/Profiler/RecordToggle.js @@ -16,11 +16,16 @@ export default function RecordToggle({ disabled }: Props) { ProfilerContext ); + let className = styles.InactiveRecordToggle; + if (disabled) { + className = styles.DisabledRecordToggle; + } else if (isProfiling) { + className = styles.ActiveRecordToggle; + } + return ( - ))} + {interactionIDs.map(interactionID => { + const interaction = interactions.get(interactionID); + if (interaction == null) { + throw Error(`Invalid interaction "${interactionID}"`); + } + return ( + + ); + })}
{captureScreenshots && ( diff --git a/src/devtools/views/Profiler/SidebarInteractions.js b/src/devtools/views/Profiler/SidebarInteractions.js index 992a815c49..c23a2106fc 100644 --- a/src/devtools/views/Profiler/SidebarInteractions.js +++ b/src/devtools/views/Profiler/SidebarInteractions.js @@ -13,48 +13,70 @@ export type Props = {||}; export default function SidebarInteractions(_: Props) { const { selectedInteractionID, - rendererID, rootID, selectCommitIndex, selectTab, } = useContext(ProfilerContext); - const { profilingCache } = useContext(StoreContext); + const { profilerStore } = useContext(StoreContext); + const { profilingCache } = profilerStore; if (selectedInteractionID === null) { return
Nothing selected
; } - const interactions = profilingCache.Interactions.read({ - rendererID: ((rendererID: any): number), - rootID: ((rootID: any): number), - }); - const interaction = interactions.find( - interaction => interaction.id === selectedInteractionID + const { interactionCommits, interactions } = profilerStore.getDataForRoot( + ((rootID: any): number) ); + const interaction = interactions.get(selectedInteractionID); if (interaction == null) { throw Error( `Could not find interaction by selected interaction id "${selectedInteractionID}"` ); } - const profilingSummary = profilingCache.ProfilingSummary.read({ - rendererID: ((rendererID: any): number), + const { maxCommitDuration } = profilingCache.getInteractionsChartData({ rootID: ((rootID: any): number), }); - const { maxCommitDuration } = profilingCache.getInteractionsChartData({ - interactions, - profilingSummary, - }); - - const { commitDurations, commitTimes } = profilingSummary; - const viewCommit = (commitIndex: number) => { selectTab('flame-chart'); selectCommitIndex(commitIndex); }; + const listItems: Array = []; + const commitIndices = interactionCommits.get(selectedInteractionID); + if (commitIndices != null) { + commitIndices.forEach(commitIndex => { + const { duration, timestamp } = profilerStore.getCommitData( + ((rootID: any): number), + commitIndex + ); + + listItems.push( +
  • viewCommit(commitIndex)} + > +
    +
    + timestamp: {formatTime(timestamp)}s +
    + duration: {formatDuration(duration)}ms +
    +
  • + ); + }); + } + return (
    @@ -62,35 +84,7 @@ export default function SidebarInteractions(_: Props) {
    Commits:
    -
      - {interaction.commits.map(commitIndex => ( -
    • viewCommit(commitIndex)} - > -
      -
      - timestamp: {formatTime(commitTimes[commitIndex])}s -
      - duration: {formatDuration(commitDurations[commitIndex])}ms -
      -
    • - ))} -
    +
      {listItems}
    ); diff --git a/src/devtools/views/Profiler/SidebarSelectedFiberInfo.js b/src/devtools/views/Profiler/SidebarSelectedFiberInfo.js index 8739509575..c83b0bc00e 100644 --- a/src/devtools/views/Profiler/SidebarSelectedFiberInfo.js +++ b/src/devtools/views/Profiler/SidebarSelectedFiberInfo.js @@ -12,9 +12,8 @@ import styles from './SidebarSelectedFiberInfo.css'; export type Props = {||}; export default function SidebarSelectedFiberInfo(_: Props) { - const { profilingCache } = useContext(StoreContext); + const { profilerStore } = useContext(StoreContext); const { - rendererID, rootID, selectCommitIndex, selectedCommitIndex, @@ -22,23 +21,21 @@ export default function SidebarSelectedFiberInfo(_: Props) { selectedFiberName, selectFiber, } = useContext(ProfilerContext); + const { profilingCache } = profilerStore; - const { commitTimes } = profilingCache.ProfilingSummary.read({ - rendererID: ((rendererID: any): number), - rootID: ((rootID: any): number), - }); - - const { commitDurations } = profilingCache.FiberCommits.read({ + const commitIndices = profilingCache.getFiberCommits({ fiberID: ((selectedFiberID: any): number), - rendererID: ((rendererID: any): number), rootID: ((rootID: any): number), }); const listItems = []; - for (let i = 0; i < commitDurations.length; i += 2) { - const commitIndex = commitDurations[i]; - const duration = commitDurations[i + 1]; - const time = commitTimes[commitIndex]; + for (let i = 0; i < commitIndices.length; i += 2) { + const commitIndex = commitIndices[i]; + + const { duration, timestamp } = profilerStore.getCommitData( + ((rootID: any): number), + commitIndex + ); listItems.push( ); } diff --git a/src/devtools/views/Profiler/SnapshotCommitListItem.css b/src/devtools/views/Profiler/SnapshotCommitListItem.css index e017a26c14..dc6592a931 100644 --- a/src/devtools/views/Profiler/SnapshotCommitListItem.css +++ b/src/devtools/views/Profiler/SnapshotCommitListItem.css @@ -11,5 +11,6 @@ .Inner { width: 100%; min-height: 5px; - background-color: var(--color-commit-did-not-render); + background-color: var(--color-commit-did-not-render-fill); + color: var(--color-commit-did-not-render-fill-text); } diff --git a/src/devtools/views/Profiler/SnapshotSelector.js b/src/devtools/views/Profiler/SnapshotSelector.js index f609d2cdcf..a41187d89d 100644 --- a/src/devtools/views/Profiler/SnapshotSelector.js +++ b/src/devtools/views/Profiler/SnapshotSelector.js @@ -16,29 +16,33 @@ export default function SnapshotSelector(_: Props) { const { isCommitFilterEnabled, minCommitDuration, - rendererID, rootID, selectedCommitIndex, selectCommitIndex, } = useContext(ProfilerContext); - const { profilingCache } = useContext(StoreContext); - const { commitDurations, commitTimes } = profilingCache.ProfilingSummary.read( - { - rendererID: ((rendererID: any): number), - rootID: ((rootID: any): number), - } - ); + const { profilerStore } = useContext(StoreContext); + const { commitData } = profilerStore.getDataForRoot(((rootID: any): number)); + + const commitDurations: Array = []; + const commitTimes: Array = []; + commitData.forEach(commitDatum => { + commitDurations.push(commitDatum.duration); + commitTimes.push(commitDatum.timestamp); + }); const filteredCommitIndices = useMemo( () => - commitDurations.reduce((reduced, commitDuration, index) => { - if (!isCommitFilterEnabled || commitDuration >= minCommitDuration) { + commitData.reduce((reduced, commitDatum, index) => { + if ( + !isCommitFilterEnabled || + commitDatum.duration >= minCommitDuration + ) { reduced.push(index); } return reduced; }, []), - [commitDurations, isCommitFilterEnabled, minCommitDuration] + [commitData, isCommitFilterEnabled, minCommitDuration] ); const numFilteredCommits = filteredCommitIndices.length; @@ -112,7 +116,7 @@ export default function SnapshotSelector(_: Props) { [viewNextCommit, viewPrevCommit] ); - if (commitDurations.length === 0) { + if (commitData.length === 0) { return null; } diff --git a/src/devtools/views/Profiler/types.js b/src/devtools/views/Profiler/types.js index 0da7a99225..9af30779be 100644 --- a/src/devtools/views/Profiler/types.js +++ b/src/devtools/views/Profiler/types.js @@ -1,72 +1,124 @@ // @flow -export type CommitTreeNodeFrontend = {| +import type { ElementType } from 'src/types'; + +export type CommitTreeNode = {| id: number, children: Array, displayName: string | null, key: number | string | null, parentID: number, treeBaseDuration: number, + type: ElementType, |}; -export type CommitTreeFrontend = {| - nodes: Map, +export type CommitTree = {| + nodes: Map, rootID: number, |}; -export type InteractionFrontend = {| +export type Interaction = {| id: number, name: string, timestamp: number, |}; -export type InteractionWithCommitsFrontend = {| - ...InteractionFrontend, - commits: Array, -|}; - -export type InteractionsFrontend = Array; - -export type CommitDetailsFrontend = {| - rootID: number, - commitIndex: number, - actualDurations: Map, - interactions: Array, -|}; - -export type FiberCommitsFrontend = {| - commitDurations: Array, - fiberID: number, - rootID: number, -|}; - -export type ProfilingSummaryFrontend = {| - rootID: number, - - // Commit durations - commitDurations: Array, - - // Commit times (relative to when profiling started) - commitTimes: Array, - - // Map of fiber id to (initial) tree base duration - initialTreeBaseDurations: Map, - - interactionCount: number, -|}; - -export type ProfilingSnapshotNode = {| +export type SnapshotNode = {| id: number, children: Array, displayName: string | null, key: number | string | null, + type: ElementType, |}; -export type ImportedProfilingData = {| - version: number, - profilingOperations: Map>, - profilingSnapshots: Map>, - commitDetails: CommitDetailsFrontend, - interactions: InteractionsFrontend, - profilingSummary: ProfilingSummaryFrontend, +export type CommitDataFrontend = {| + // How long was this commit? + duration: number, + + // Map of Fiber (ID) to actual duration for this commit; + // Fibers that did not render will not have entries in this Map. + fiberActualDurations: Map, + + // Map of Fiber (ID) to "self duration" for this commit; + // Fibers that did not render will not have entries in this Map. + fiberSelfDurations: Map, + + // Which interactions (IDs) were associated with this commit. + interactionIDs: Array, + + // Priority level of the commit (if React provided this info) + priorityLevel: string | null, + + // Screenshot data for this commit (if available). + screenshot: string | null, + + // When did this commit occur (relative to the start of profiling) + timestamp: number, +|}; + +export type ProfilingDataForRootFrontend = {| + // Timing, duration, and other metadata about each commit. + commitData: Array, + + // Display name of the nearest descendant component (ideally a function or class component). + // This value is used by the root selector UI. + displayName: string, + + // Map of fiber id to (initial) tree base duration when Profiling session was started. + // This info can be used along with commitOperations to reconstruct the tree for any commit. + initialTreeBaseDurations: Map, + + // All interactions recorded (for this root) during the current session. + interactionCommits: Map>, + + // All interactions recorded (for this root) during the current session. + interactions: Map, + + // List of tree mutation that occur during profiling. + // These mutations can be used along with initial snapshots to reconstruct the tree for any commit. + operations: Array, + + // Identifies the root this profiler data corresponds to. + rootID: number, + + // Map of fiber id to node when the Profiling session was started. + // This info can be used along with commitOperations to reconstruct the tree for any commit. + snapshots: Map, +|}; + +// Combination of profiling data collected by the renderer interface (backend) and Store (frontend). +export type ProfilingDataFrontend = {| + // Profiling data per root. + dataForRoots: Map, +|}; + +export type CommitDataExport = {| + 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, + screenshot: string | null, + timestamp: number, +|}; + +export type ProfilingDataForRootExport = {| + 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]>, + operations: Array>, + rootID: number, + snapshots: Array<[number, SnapshotNode]>, +|}; + +// Serializable vefrsion of ProfilingDataFrontend data. +export type ProfilingDataExport = {| + version: 4, + dataForRoots: Array, |}; diff --git a/src/devtools/views/Profiler/utils.js b/src/devtools/views/Profiler/utils.js index 93443d9714..f192520f4f 100644 --- a/src/devtools/views/Profiler/utils.js +++ b/src/devtools/views/Profiler/utils.js @@ -2,10 +2,13 @@ import { PROFILER_EXPORT_VERSION } from 'src/constants'; +import type { ProfilingDataBackend } from 'src/backend/types'; import type { - CommitDetailsFrontend, - CommitTreeFrontend, - ProfilingSnapshotNode, + ProfilingDataExport, + ProfilingDataForRootExport, + ProfilingDataForRootFrontend, + ProfilingDataFrontend, + SnapshotNode, } from './types'; const commitGradient = [ @@ -21,83 +24,176 @@ const commitGradient = [ 'var(--color-commit-gradient-9)', ]; -export const calculateSelfDuration = ( - id: number, - commitTree: CommitTreeFrontend, - commitDetails: CommitDetailsFrontend -): number => { - const { actualDurations } = commitDetails; - const { nodes } = commitTree; +// Combines info from the Store (frontend) and renderer interfaces (backend) into the format required by the Profiler UI. +// This format can then be quickly exported (and re-imported). +export function prepareProfilingDataFrontendFromBackendAndStore( + dataBackends: Array, + operationsByRootID: Map>, + screenshotsByRootID: Map>, + snapshotsByRootID: Map> +): ProfilingDataFrontend { + const dataForRoots: Map = new Map(); - if (!actualDurations.has(id)) { - return 0; - } + dataBackends.forEach(dataBackend => { + dataBackend.dataForRoots.forEach( + ({ + commitData, + displayName, + initialTreeBaseDurations, + interactionCommits, + interactions, + rootID, + }) => { + const screenshots = screenshotsByRootID.get(rootID) || null; - const node = nodes.get(id); - if (node == null) { - throw Error(`Could not find node with id "${id}" in commit tree`); - } + const operations = operationsByRootID.get(rootID); + if (operations == null) { + throw Error(`Could not find profiling operations for root ${rootID}`); + } - let selfDuration = actualDurations.get(id) || 0; + const snapshots = snapshotsByRootID.get(rootID); + if (snapshots == null) { + throw Error(`Could not find profiling snapshots for root ${rootID}`); + } - node.children.forEach(childID => { - if (actualDurations.has(childID)) { - selfDuration -= actualDurations.get(childID) || 0; - } + dataForRoots.set(rootID, { + commitData: commitData.map((commitDataBackend, commitIndex) => ({ + duration: commitDataBackend.duration, + fiberActualDurations: new Map( + commitDataBackend.fiberActualDurations + ), + fiberSelfDurations: new Map(commitDataBackend.fiberSelfDurations), + interactionIDs: commitDataBackend.interactionIDs, + priorityLevel: commitDataBackend.priorityLevel, + screenshot: + (screenshots !== null && screenshots.get(commitIndex)) || null, + timestamp: commitDataBackend.timestamp, + })), + displayName, + initialTreeBaseDurations: new Map(initialTreeBaseDurations), + interactionCommits: new Map(interactionCommits), + interactions: new Map(interactions), + operations, + rootID, + snapshots, + }); + } + ); }); - return selfDuration; -}; + return { dataForRoots }; +} -export const prepareProfilingExport = ( - profilingOperations: Map>, - profilingSnapshots: Map>, - rendererID: number, - rootID: number -) => { - const profilingOperationsForRoot = []; - const operations = profilingOperations.get(rootID); - if (operations != null) { - operations.forEach(operations => { - // Convert typed Array before JSON serialization, or it will be converted to an Object. - profilingOperationsForRoot.push(Array.from(operations)); - }); +// Converts a Profiling data export into the format required by the Store. +export function prepareProfilingDataFrontendFromExport( + profilingDataExport: ProfilingDataExport +): ProfilingDataFrontend { + const { version } = profilingDataExport; + + if (version !== PROFILER_EXPORT_VERSION) { + throw Error(`Unsupported profiler export version "${version}"`); } - // Convert Map to Object or JSON.stringify will clobber the contents. - const profilingSnapshotsForRoot = {}; - const profilingSnapshotsMap = profilingSnapshots.get(rootID); - if (profilingSnapshotsMap != null) { - for (let [id, snapshot] of profilingSnapshotsMap.entries()) { - profilingSnapshotsForRoot[id] = snapshot; + const dataForRoots: Map = new Map(); + profilingDataExport.dataForRoots.forEach( + ({ + commitData, + displayName, + initialTreeBaseDurations, + interactionCommits, + interactions, + operations, + rootID, + snapshots, + }) => { + dataForRoots.set(rootID, { + commitData: commitData.map( + ({ + duration, + fiberActualDurations, + fiberSelfDurations, + interactionIDs, + priorityLevel, + screenshot, + timestamp, + }) => ({ + duration, + fiberActualDurations: new Map(fiberActualDurations), + fiberSelfDurations: new Map(fiberSelfDurations), + interactionIDs, + priorityLevel, + screenshot, + timestamp, + }) + ), + displayName, + initialTreeBaseDurations: new Map(initialTreeBaseDurations), + interactionCommits: new Map(interactionCommits), + interactions: new Map(interactions), + operations: operations.map(array => Uint32Array.from(array)), // Convert Array back to Uint32Array + rootID, + snapshots: new Map(snapshots), + }); } - } + ); + + return { dataForRoots }; +} + +// Converts a Store Profiling data into a format that can be safely (JSON) serialized for export. +export function prepareProfilingDataExport( + profilingDataFrontend: ProfilingDataFrontend +): ProfilingDataExport { + const dataForRoots: Array = []; + profilingDataFrontend.dataForRoots.forEach( + ({ + commitData, + displayName, + initialTreeBaseDurations, + interactionCommits, + interactions, + operations, + rootID, + snapshots, + }) => { + dataForRoots.push({ + commitData: commitData.map( + ({ + duration, + fiberActualDurations, + fiberSelfDurations, + interactionIDs, + priorityLevel, + screenshot, + timestamp, + }) => ({ + duration, + fiberActualDurations: Array.from(fiberActualDurations.entries()), + fiberSelfDurations: Array.from(fiberSelfDurations.entries()), + interactionIDs, + priorityLevel, + screenshot, + timestamp, + }) + ), + displayName, + initialTreeBaseDurations: Array.from( + initialTreeBaseDurations.entries() + ), + interactionCommits: Array.from(interactionCommits.entries()), + interactions: Array.from(interactions.entries()), + operations: operations.map(array => Array.from(array)), // Convert Uint32Array to Array for serialization + rootID, + snapshots: Array.from(snapshots.entries()), + }); + } + ); return { - profilingOperations: profilingOperationsForRoot, - profilingSnapshots: profilingSnapshotsForRoot, - rendererID, - rootID, + version: PROFILER_EXPORT_VERSION, + dataForRoots, }; -}; - -export const prepareProfilingImport = (raw: string) => { - const parsed = JSON.parse(raw); - - if (parsed.version !== PROFILER_EXPORT_VERSION) { - throw Error(`Unsupported profiler export version "${parsed.version}".`); - } - - const entries = []; - Object.values(parsed.profilingSnapshots).forEach(snapshot => { - entries.push([(snapshot: any).id, snapshot]); - }); - - const rootID = parsed.profilingSummary.rootID; - parsed.profilingOperations = new Map([[rootID, parsed.profilingOperations]]); - parsed.profilingSnapshots = new Map([[rootID, new Map(entries)]]); - return parsed; -}; +} export const getGradientColor = (value: number) => { const maxIndex = commitGradient.length - 1; diff --git a/src/devtools/views/Settings/SettingsContext.js b/src/devtools/views/Settings/SettingsContext.js index 0136c8bbc8..17194aaa03 100644 --- a/src/devtools/views/Settings/SettingsContext.js +++ b/src/devtools/views/Settings/SettingsContext.js @@ -214,7 +214,26 @@ function updateThemeVariables( updateStyleHelper(theme, 'color-button-disabled', documentElements); updateStyleHelper(theme, 'color-button-focus', documentElements); updateStyleHelper(theme, 'color-button-hover', documentElements); - updateStyleHelper(theme, 'color-commit-did-not-render', documentElements); + updateStyleHelper( + theme, + 'color-commit-did-not-render-fill', + documentElements + ); + updateStyleHelper( + theme, + 'color-commit-did-not-render-fill-text', + documentElements + ); + updateStyleHelper( + theme, + 'color-commit-did-not-render-pattern', + documentElements + ); + updateStyleHelper( + theme, + 'color-commit-did-not-render-pattern-text', + documentElements + ); updateStyleHelper(theme, 'color-commit-gradient-0', documentElements); updateStyleHelper(theme, 'color-commit-gradient-1', documentElements); updateStyleHelper(theme, 'color-commit-gradient-2', documentElements); @@ -228,6 +247,16 @@ function updateThemeVariables( updateStyleHelper(theme, 'color-commit-gradient-text', documentElements); updateStyleHelper(theme, 'color-component-name', documentElements); updateStyleHelper(theme, 'color-component-name-inverted', documentElements); + updateStyleHelper( + theme, + 'color-component-badge-background', + documentElements + ); + updateStyleHelper( + theme, + 'color-component-badge-background-inverted', + documentElements + ); updateStyleHelper(theme, 'color-dim', documentElements); updateStyleHelper(theme, 'color-dimmer', documentElements); updateStyleHelper(theme, 'color-dimmest', documentElements); diff --git a/src/devtools/views/TabBar.js b/src/devtools/views/TabBar.js index 87031f7e2c..53733b7ff8 100644 --- a/src/devtools/views/TabBar.js +++ b/src/devtools/views/TabBar.js @@ -2,9 +2,11 @@ import classNames from 'classnames'; import React, { Fragment, useCallback } from 'react'; +import Tooltip from '@reach/tooltip'; import Icon from './Icon'; import styles from './TabBar.css'; +import tooltipStyles from './Tooltip.css'; import type { IconType } from './Icon'; @@ -59,42 +61,53 @@ export default function TabBar({ return ( - {tabs.map(({ icon, id, label, title }) => ( - - ))} + + + + {label} + + + ); + + if (title) { + button = ( + + {button} + + ); + } + + return button; + })} ); } diff --git a/src/devtools/views/root.css b/src/devtools/views/root.css index 296d7dd1a3..5dba64cc20 100644 --- a/src/devtools/views/root.css +++ b/src/devtools/views/root.css @@ -19,7 +19,10 @@ --light-color-button-focus: #23272f; --light-color-button-hover: #23272f; --light-color-border: #eeeeee; - --light-color-commit-did-not-render: #cfd1d5; + --light-color-commit-did-not-render-fill: #cfd1d5; + --light-color-commit-did-not-render-fill-text: #000000; + --light-color-commit-did-not-render-pattern: #cfd1d5; + --light-color-commit-did-not-render-pattern-text: #333333; --light-color-commit-gradient-0: #37afa9; --light-color-commit-gradient-1: #63b19e; --light-color-commit-gradient-2: #80b393; @@ -33,6 +36,8 @@ --light-color-commit-gradient-text: #000000; --light-color-component-name: #6a51b2; --light-color-component-name-inverted: #ffffff; + --light-color-component-badge-background: rgba(0, 0, 0, 0.15); + --light-color-component-badge-background-inverted: rgba(255, 255, 255, 0.25); --light-color-dim: #777d88; --light-color-dimmer: #cfd1d5; --light-color-dimmest: #eff0f1; @@ -67,11 +72,14 @@ --dark-color-button-background-focus: #3d424a; --dark-color-button: #afb3b9; --dark-color-button-active: #61dafb; - --dark-color-button-disabled: #777d88; + --dark-color-button-disabled: #4f5766; --dark-color-button-focus: #a2e9fc; --dark-color-button-hover: #ededed; --dark-color-border: #3d424a; - --dark-color-commit-did-not-render: #777d88; + --dark-color-commit-did-not-render-fill: #777d88; + --dark-color-commit-did-not-render-fill-text: #000000; + --dark-color-commit-did-not-render-pattern: #666c77; + --dark-color-commit-did-not-render-pattern-text: #ffffff; --dark-color-commit-gradient-0: #37afa9; --dark-color-commit-gradient-1: #63b19e; --dark-color-commit-gradient-2: #80b393; @@ -85,6 +93,8 @@ --dark-color-commit-gradient-text: #000000; --dark-color-component-name: #61dafb; --dark-color-component-name-inverted: ##282828; + --dark-color-component-badge-background: rgba(255, 255, 255, 0.25); + --dark-color-component-badge-background-inverted: rgba(0, 0, 0, 0.25); --dark-color-dim: #8f949d; --dark-color-dimmer: #777d88; --dark-color-dimmest: #4f5766; diff --git a/src/devtools/views/utils.js b/src/devtools/views/utils.js index 2db6fb82a7..8cebefa319 100644 --- a/src/devtools/views/utils.js +++ b/src/devtools/views/utils.js @@ -146,3 +146,27 @@ export function serializeHooksForCopy(hooks: HooksTree | null): string { return ''; } } + +// Keeping this in memory seems to be enough to enable the browser to download larger profiles. +// Without this, we would see a "Download failed: network error" failure. +let downloadUrl = null; + +export function downloadFile(filename: string, text: string): void { + const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); + + if (downloadUrl !== null) { + URL.revokeObjectURL(downloadUrl); + } + + downloadUrl = URL.createObjectURL(blob); + + const element = document.createElement('a'); + element.setAttribute('href', downloadUrl); + element.setAttribute('download', filename); + element.style.display = 'none'; + ((document.body: any): HTMLBodyElement).appendChild(element); + + element.click(); + + ((document.body: any): HTMLBodyElement).removeChild(element); +} diff --git a/src/hook.js b/src/hook.js index 23f7280839..98c1794d35 100644 --- a/src/hook.js +++ b/src/hook.js @@ -138,7 +138,7 @@ export function installHook(target: any): DevToolsHook | null { } } - function onCommitFiberRoot(rendererID, root) { + function onCommitFiberRoot(rendererID, root, priorityLevel) { const mountedRoots = hook.getFiberRoots(rendererID); const current = root.current; const isKnownRoot = mountedRoots.has(root); @@ -153,7 +153,7 @@ export function installHook(target: any): DevToolsHook | null { } const rendererInterface = rendererInterfaces.get(rendererID); if (rendererInterface != null) { - rendererInterface.handleCommitFiberRoot(root); + rendererInterface.handleCommitFiberRoot(root, priorityLevel); } } diff --git a/src/hydration.js b/src/hydration.js index 329994119f..48a83af0c6 100644 --- a/src/hydration.js +++ b/src/hydration.js @@ -272,7 +272,7 @@ export function getDisplayNameForReactElement( if (typeof type === 'string') { return type; } else if (type != null) { - return getDisplayName(type, 'Unknown'); + return getDisplayName(type, 'Anonymous'); } else { return 'Element'; } diff --git a/src/utils.js b/src/utils.js index 499a72bb1d..3880bada6a 100644 --- a/src/utils.js +++ b/src/utils.js @@ -15,7 +15,7 @@ let encodedStringCache = new LRU({ max: 1000 }); export function getDisplayName( type: Function, - fallbackName: string = 'Unknown' + fallbackName: string = 'Anonymous' ): string { const nameFromCache = cachedDisplayNames.get(type); if (nameFromCache != null) { diff --git a/yarn.lock b/yarn.lock index 040e1782d5..71262041f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1474,7 +1474,7 @@ addons-linter@1.6.1: optionalDependencies: fsevents "2.0.1" -adm-zip@^0.4.7, adm-zip@~0.4.x: +adm-zip@~0.4.x: version "0.4.13" resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.13.tgz#597e2f8cc3672151e1307d3e95cddbc75672314a" integrity sha512-fERNJX8sOXfel6qCBCMPvZLzENBEhZTzKqg6vrOW5pvoEaQuJhRU4ndTAh6lHOxn1I6jnz2NHra56ZODM751uw== @@ -1834,7 +1834,7 @@ asn1.js@^4.0.0: inherits "^2.0.1" minimalistic-assert "^1.0.0" -asn1@^0.2.4, asn1@~0.2.3: +asn1@~0.2.3: version "0.2.4" resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== @@ -3202,7 +3202,7 @@ commander@^2.11.0, commander@^2.14.1, commander@^2.9.0: resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a" integrity sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg== -commander@^2.19.0, commander@^2.3.0, commander@^2.6.0: +commander@^2.3.0, commander@^2.6.0: version "2.20.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422" integrity sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ== @@ -3691,15 +3691,6 @@ crx-parser@0.1.2: resolved "https://registry.yarnpkg.com/crx-parser/-/crx-parser-0.1.2.tgz#7eeeed9eddc95e22c189382e34624044a89a5a6d" integrity sha1-fu7tnt3JXiLBiTguNGJARKiaWm0= -"crx@git+https://github.com/oncletom/crx#ef150e8": - version "4.0.1" - resolved "git+https://github.com/oncletom/crx#ef150e8abf1b2e093ce746584680876927c0fc21" - dependencies: - archiver "^3.0.0" - commander "^2.19.0" - node-rsa "^1.0.3" - pbf "^3.1.0" - cryptiles@3.x.x: version "3.1.2" resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-3.1.2.tgz#a89fbb220f5ce25ec56e8c4aa8a4fd7b5b0d29fe" @@ -6242,11 +6233,6 @@ icss-utils@^2.1.0: dependencies: postcss "^6.0.1" -ieee754@^1.1.12: - version "1.1.13" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" - integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== - ieee754@^1.1.4: version "1.1.12" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.12.tgz#50bf24e5b9c8bb98af4964c941cdb0918da7b60b" @@ -8669,13 +8655,6 @@ node-releases@^1.1.8: dependencies: semver "^5.3.0" -node-rsa@^1.0.3: - version "1.0.5" - resolved "https://registry.yarnpkg.com/node-rsa/-/node-rsa-1.0.5.tgz#854dc1b275729d69bc25883f83ca80705db9262e" - integrity sha512-9o51yfV167CtQANnuAf+5owNs7aIMsAKVLhNaKuRxihsUUnfoBMN5OTVOK/2mHSOWaWq9zZBiRM3bHORbTZqrg== - dependencies: - asn1 "^0.2.4" - node-version@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/node-version/-/node-version-1.0.0.tgz#1b9b9584a9a7f7a6123f215cd14a652bf21ab19e" @@ -9254,14 +9233,6 @@ path-type@^3.0.0: dependencies: pify "^3.0.0" -pbf@^3.1.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/pbf/-/pbf-3.2.0.tgz#e76f9f5114e395c25077ad6fe463b3507d6877fc" - integrity sha512-98Eh7rsJNJF/Im6XYMLaOW3cLnNyedlOd6hu3iWMD5I7FZGgpw8yN3vQBrmLbLodu7G784Irb9Qsv2yFrxSAGw== - dependencies: - ieee754 "^1.1.12" - resolve-protobuf-schema "^2.1.0" - pbkdf2-compat@2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/pbkdf2-compat/-/pbkdf2-compat-2.0.1.tgz#b6e0c8fa99494d94e0511575802a59a5c142f288" @@ -9562,11 +9533,6 @@ prop-types@^15.5.10, prop-types@^15.6.2, prop-types@^15.7.2: object-assign "^4.1.1" react-is "^16.8.1" -protocol-buffers-schema@^3.3.1: - version "3.3.2" - resolved "https://registry.yarnpkg.com/protocol-buffers-schema/-/protocol-buffers-schema-3.3.2.tgz#00434f608b4e8df54c59e070efeefc37fb4bb859" - integrity sha512-Xdayp8sB/mU+sUV4G7ws8xtYMGdQnxbeIfLjyO9TZZRJdztBGhlmbI5x1qcY4TG5hBkIKGnc28i7nXxaugu88w== - proxy-addr@~2.0.4: version "2.0.5" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.5.tgz#34cbd64a2d81f4b1fd21e76f9f06c8a45299ee34" @@ -9748,20 +9714,20 @@ react-color@^2.11.7: reactcss "^1.2.0" tinycolor2 "^1.4.1" -react-dom@^0.0.0-6da04b5d8: - version "0.0.0-6da04b5d8" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-0.0.0-6da04b5d8.tgz#ee78e45a40771560c756b8fc7f1fa213f2179ebe" - integrity sha512-6oyfkucrweqCB5XyLsfEnPSWhvkFnttutkU9uUQovLAljuazgpAjvBy6MBGSewKptqch2OTNwopqvR3QUMD8AQ== +react-dom@^0.0.0-50b50c26f: + version "0.0.0-50b50c26f" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-0.0.0-50b50c26f.tgz#3cd8da0f2276ed4b7a926e1807d2675b2eb40227" + integrity sha512-da9qleWDdBdAguEIDvvpFE0iuS8hfcCSGgZTYKRQMlSh5A94Ktr1otL4rgDTFH+bNsOwz3XrvEBYRA6WaE9xzQ== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" prop-types "^15.6.2" - scheduler "0.0.0-6da04b5d8" + scheduler "0.0.0-50b50c26f" -react-is@0.0.0-6da04b5d8, react-is@^0.0.0-6da04b5d8: - version "0.0.0-6da04b5d8" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-0.0.0-6da04b5d8.tgz#ded1f02e9f1e2b8456812d0d45c341d70b7bf7db" - integrity sha512-+Df3meqx+XUir+3dCqiHNAHruwmOAgXVp3TYmlUvgtPNBu+LF0OdchTMZ/xMUok1gYVoe4l/xhfs9CTEPkWt3g== +react-is@0.0.0-50b50c26f, react-is@^0.0.0-50b50c26f: + version "0.0.0-50b50c26f" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-0.0.0-50b50c26f.tgz#c4003ffffef9bd2b287979f9041a23d12a607bf2" + integrity sha512-9Y6ZvdOVmOxXs9mGuFy6eXHBww8RJCtJAh94b1hkbjhnW8Mb5ADScDoxJBVxcNuX9hvDkhENspC96ZQK1NIv3g== react-is@^16.8.1: version "16.8.3" @@ -9773,15 +9739,15 @@ react-is@^16.8.4: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.4.tgz#90f336a68c3a29a096a3d648ab80e87ec61482a2" integrity sha512-PVadd+WaUDOAciICm/J1waJaSvgq+4rHE/K70j0PFqKhkTBsPv/82UGQJNXAngz1fOQLLxI6z1sEDmJDQhCTAA== -react-test-renderer@^0.0.0-6da04b5d8: - version "0.0.0-6da04b5d8" - resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-0.0.0-6da04b5d8.tgz#01bed04c5a4cf22339f0ae3b23f89bb45e9a8f2a" - integrity sha512-yDt5RPDXLZXTqlWS0jXMJ1IyS1e/UZXSr0L8bG0UCsna4T7A3HIYR2zChlydGXsxjGMZntKqEfm27hUhFgC06Q== +react-test-renderer@^0.0.0-50b50c26f: + version "0.0.0-50b50c26f" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-0.0.0-50b50c26f.tgz#1a85cf9073ef5a932d03bee36fcfd9bf15aeae2c" + integrity sha512-gWc4L+mFIUCjvBpafR88n4/i/oaKHD6rzVyZY+XBou9MNtr2rRkjePOhBVsiYlCwkj+zZi6klV9b05TMzftosA== dependencies: object-assign "^4.1.1" prop-types "^15.6.2" - react-is "0.0.0-6da04b5d8" - scheduler "0.0.0-6da04b5d8" + react-is "0.0.0-50b50c26f" + scheduler "0.0.0-50b50c26f" react-virtualized-auto-sizer@^1.0.2: version "1.0.2" @@ -9796,15 +9762,14 @@ react-window@^1.8.0: "@babel/runtime" "^7.0.0" memoize-one ">=3.1.1 <6" -react@^0.0.0-6da04b5d8: - version "0.0.0-6da04b5d8" - resolved "https://registry.yarnpkg.com/react/-/react-0.0.0-6da04b5d8.tgz#583d81f73b26771da41170a5042a5ab0bdcfe37a" - integrity sha512-8hXBHwDCKxSVFqj5Kb4OskZz7//2fx2IpUnYyukYV8qyAHlXr0qUl3GxwuryhdPzJHlzi776WjN0YEmEEANhYA== +react@^0.0.0-50b50c26f: + version "0.0.0-50b50c26f" + resolved "https://registry.yarnpkg.com/react/-/react-0.0.0-50b50c26f.tgz#b782b579ce1f5d8bd696c5e45c744714ebecb111" + integrity sha512-jUAzS4DeWTdUZ/3kqm2T6C9OIpiAf2qdwVamCts0qzwYVni1/gUTOWK1ui0J+eaRzKxrIEzVvmCMxFd35lP/pA== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" prop-types "^15.6.2" - scheduler "0.0.0-6da04b5d8" reactcss@^1.2.0: version "1.2.3" @@ -10284,13 +10249,6 @@ resolve-from@^4.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== -resolve-protobuf-schema@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz#9ca9a9e69cf192bbdaf1006ec1973948aa4a3758" - integrity sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ== - dependencies: - protocol-buffers-schema "^3.3.1" - resolve-url@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" @@ -10460,10 +10418,10 @@ sax@>=0.6.0, sax@^1.2.4: resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== -scheduler@0.0.0-6da04b5d8, scheduler@^0.0.0-6da04b5d8: - version "0.0.0-6da04b5d8" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.0.0-6da04b5d8.tgz#5e0ec65c2b0a7f05ffdc5522fc3a6d95b693e5c9" - integrity sha512-upTrWBZvk4IjMsC/AcRpgCwjnSQl8i78+07KmcndqWOnWp7s4wauowWXhyswP9vucLtZaN5ussFdM3d7dXcTkw== +scheduler@0.0.0-50b50c26f, scheduler@^0.0.0-50b50c26f: + version "0.0.0-50b50c26f" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.0.0-50b50c26f.tgz#09bedde1c64d7a042b557bee2dbf5faf5fd58a50" + integrity sha512-LBN3zrP8iBdILOoYxybFtkU7j+ldZTHORKyYyVLwXuIwGQ8/Xhs5VZjNQ5R2Xru2zv3GGVpJSbd47EpDuD2EHw== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" @@ -12326,13 +12284,6 @@ xdg-basedir@^3.0.0: resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4" integrity sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ= -xml-js@^1.6.11: - version "1.6.11" - resolved "https://registry.yarnpkg.com/xml-js/-/xml-js-1.6.11.tgz#927d2f6947f7f1c19a316dd8eea3614e8b18f8e9" - integrity sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g== - dependencies: - sax "^1.2.4" - xml-name-validator@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"