From 0f2fb5badf5908ee271e0b9e7ec7f3022e31a648 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sat, 13 Jul 2019 10:05:04 -0700 Subject: [PATCH] Standalone NPM packages and React Native support (#335) * Add version 4 react-devtools and react-devtools-core packages which support both React Native and e.g. Safari or iframe DOM usage. * Replaces typed operations arrays with regular arrays in order to support Hermes. This is unfortunate, since in theory a typed array buffer could be more efficiently transferred between frontend and backend for the web extension, but this never actually worked properly in v8, only Spidermonkey, and it fails entirely in Hermes so for the time being- it's been removed. * Adds support for React Native (paper renderer) * Adds a style editor for react-native and react-native-web --- .gitignore | 1 + babel.config.js | 3 + fixtures/standalone/index.html | 284 ++++++++++++++++ flow-typed/chrome.js | 94 ++++++ package.json | 7 +- packages/react-devtools-core/README.md | 44 +++ packages/react-devtools-core/backend.js | 1 + packages/react-devtools-core/package.json | 31 ++ packages/react-devtools-core/src/backend.js | 269 +++++++++++++++ .../react-devtools-core/src/launchEditor.js | 170 ++++++++++ .../react-devtools-core/src/standalone.js | 286 ++++++++++++++++ packages/react-devtools-core/standalone.js | 1 + .../react-devtools-core/webpack.backend.js | 53 +++ .../react-devtools-core/webpack.standalone.js | 69 ++++ packages/react-devtools/README.md | 98 ++++++ packages/react-devtools/app.html | 129 +++++++ packages/react-devtools/app.js | 50 +++ packages/react-devtools/bin.js | 18 + packages/react-devtools/icons/icon128.png | Bin 0 -> 4577 bytes packages/react-devtools/index.js | 7 + packages/react-devtools/package.json | 31 ++ shells/browser/shared/src/backend.js | 12 + shells/browser/shared/src/main.js | 16 +- shells/browser/shared/src/utils.js | 8 +- .../InspectableElements.js | 2 + .../app/InspectableElements/SimpleValues.js | 23 ++ shells/dev/src/devtools.js | 1 - .../inspectedElementContext-test.js.snap | 23 ++ .../__snapshots__/profilingCache-test.js.snap | 10 +- src/__tests__/inspectedElementContext-test.js | 66 ++++ .../__snapshots__/inspectElement-test.js.snap | 27 ++ src/__tests__/legacy/inspectElement-test.js | 43 +++ .../NativeStyleEditor/resolveBoxStyle.js | 85 +++++ .../setupNativeStyleEditor.js | 315 ++++++++++++++++++ src/backend/NativeStyleEditor/types.js | 27 ++ src/backend/agent.js | 205 +++--------- src/backend/legacy/renderer.js | 34 +- src/backend/renderer.js | 36 +- src/backend/types.js | 11 + .../views/{ => Highlighter}/Highlighter.js | 5 + .../views/{ => Highlighter}/Overlay.js | 0 src/backend/views/Highlighter/index.js | 142 ++++++++ src/bridge.js | 34 +- src/devtools/ProfilerStore.js | 9 +- src/devtools/store.js | 61 +++- src/devtools/views/Components/Components.css | 8 + src/devtools/views/Components/Components.js | 9 +- src/devtools/views/Components/Element.css | 5 - src/devtools/views/Components/Element.js | 2 - .../Components/InspectHostNodesToggle.js | 10 +- .../NativeStyleEditor/AutoSizeInput.css | 26 ++ .../NativeStyleEditor/AutoSizeInput.js | 93 ++++++ .../NativeStyleEditor/LayoutViewer.css | 50 +++ .../NativeStyleEditor/LayoutViewer.js | 64 ++++ .../NativeStyleEditor/StyleEditor.css | 52 +++ .../NativeStyleEditor/StyleEditor.js | 278 ++++++++++++++++ .../Components/NativeStyleEditor/context.js | 187 +++++++++++ .../Components/NativeStyleEditor/index.js | 68 ++++ .../Components/NativeStyleEditor/types.js | 13 + .../views/Components/SelectedElement.css | 12 - .../views/Components/SelectedElement.js | 21 +- src/devtools/views/Components/Tree.js | 31 +- .../Components/ViewElementSourceContext.js | 9 +- src/devtools/views/DevTools.css | 18 +- src/devtools/views/DevTools.js | 27 +- .../views/Profiler/CommitTreeBuilder.js | 2 +- src/devtools/views/Profiler/Profiler.css | 5 + src/devtools/views/Profiler/Profiler.js | 34 +- .../Profiler/SidebarSelectedFiberInfo.css | 12 +- src/devtools/views/Profiler/types.js | 2 +- src/devtools/views/Profiler/utils.js | 6 +- .../views/Settings/SettingsContext.js | 8 +- src/devtools/views/TabBar.css | 3 + src/devtools/views/root.css | 8 +- src/hydration.js | 136 +++++--- src/utils.js | 16 +- yarn.lock | 242 +++++++++++--- 77 files changed, 3911 insertions(+), 387 deletions(-) create mode 100644 fixtures/standalone/index.html create mode 100644 flow-typed/chrome.js create mode 100644 packages/react-devtools-core/README.md create mode 100644 packages/react-devtools-core/backend.js create mode 100644 packages/react-devtools-core/package.json create mode 100644 packages/react-devtools-core/src/backend.js create mode 100644 packages/react-devtools-core/src/launchEditor.js create mode 100644 packages/react-devtools-core/src/standalone.js create mode 100644 packages/react-devtools-core/standalone.js create mode 100644 packages/react-devtools-core/webpack.backend.js create mode 100644 packages/react-devtools-core/webpack.standalone.js create mode 100644 packages/react-devtools/README.md create mode 100644 packages/react-devtools/app.html create mode 100644 packages/react-devtools/app.js create mode 100755 packages/react-devtools/bin.js create mode 100644 packages/react-devtools/icons/icon128.png create mode 100644 packages/react-devtools/index.js create mode 100644 packages/react-devtools/package.json create mode 100644 shells/dev/app/InspectableElements/SimpleValues.js create mode 100644 src/backend/NativeStyleEditor/resolveBoxStyle.js create mode 100644 src/backend/NativeStyleEditor/setupNativeStyleEditor.js create mode 100644 src/backend/NativeStyleEditor/types.js rename src/backend/views/{ => Highlighter}/Highlighter.js (86%) rename src/backend/views/{ => Highlighter}/Overlay.js (100%) create mode 100644 src/backend/views/Highlighter/index.js create mode 100644 src/devtools/views/Components/NativeStyleEditor/AutoSizeInput.css create mode 100644 src/devtools/views/Components/NativeStyleEditor/AutoSizeInput.js create mode 100644 src/devtools/views/Components/NativeStyleEditor/LayoutViewer.css create mode 100644 src/devtools/views/Components/NativeStyleEditor/LayoutViewer.js create mode 100644 src/devtools/views/Components/NativeStyleEditor/StyleEditor.css create mode 100644 src/devtools/views/Components/NativeStyleEditor/StyleEditor.js create mode 100644 src/devtools/views/Components/NativeStyleEditor/context.js create mode 100644 src/devtools/views/Components/NativeStyleEditor/index.js create mode 100644 src/devtools/views/Components/NativeStyleEditor/types.js diff --git a/.gitignore b/.gitignore index 548807aab6a6..bbe2c0a3d5a7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /shells/browser/firefox/*.xpi /shells/browser/firefox/*.pem /shells/browser/shared/build +/packages/react-devtools-core/dist /shells/dev/dist build node_modules diff --git a/babel.config.js b/babel.config.js index 1d705076ff9f..6dd41082dede 100644 --- a/babel.config.js +++ b/babel.config.js @@ -24,6 +24,9 @@ module.exports = api => { } else { targets.chrome = minChromeVersion.toString(); targets.firefox = minFirefoxVersion.toString(); + + // This targets RN/Hermes. + targets.IE = '11'; } const plugins = [ ['@babel/plugin-transform-flow-strip-types'], diff --git a/fixtures/standalone/index.html b/fixtures/standalone/index.html new file mode 100644 index 000000000000..17e133bae891 --- /dev/null +++ b/fixtures/standalone/index.html @@ -0,0 +1,284 @@ + + + + + TODO List + + + + + + + + + + + + + +
+ + + diff --git a/flow-typed/chrome.js b/flow-typed/chrome.js new file mode 100644 index 000000000000..fac810912420 --- /dev/null +++ b/flow-typed/chrome.js @@ -0,0 +1,94 @@ +// @flow + +declare var chrome: { + devtools: { + network: { + onNavigated: { + addListener: (cb: (url: string) => void) => void, + removeListener: (cb: () => void) => void, + }, + }, + inspectedWindow: { + eval: (code: string, cb?: (res: any, err: ?Object) => any) => void, + tabId: number, + }, + panels: { + create: ( + title: string, + icon: string, + filename: string, + cb: (panel: { + onHidden: { + addListener: (cb: (window: Object) => void) => void, + }, + onShown: { + addListener: (cb: (window: Object) => void) => void, + }, + }) => void + ) => void, + themeName: ?string, + }, + }, + tabs: { + create: (options: Object) => void, + executeScript: (tabId: number, options: Object, fn: () => void) => void, + onUpdated: { + addListener: ( + fn: (tabId: number, changeInfo: Object, tab: Object) => void + ) => void, + }, + query: (options: Object, fn: (tabArray: Array) => void) => void, + }, + browserAction: { + setIcon: (options: { + tabId: number, + path: { [key: string]: string }, + }) => void, + setPopup: (options: { + tabId: number, + popup: string, + }) => void, + }, + runtime: { + getURL: (path: string) => string, + sendMessage: (config: Object) => void, + connect: ( + config: Object + ) => { + disconnect: () => void, + onMessage: { + addListener: (fn: (message: Object) => void) => void, + }, + onDisconnect: { + addListener: (fn: (message: Object) => void) => void, + }, + postMessage: (data: Object) => void, + }, + onConnect: { + addListener: ( + fn: (port: { + name: string, + sender: { + tab: { + id: number, + url: string, + }, + }, + }) => void + ) => void, + }, + onMessage: { + addListener: ( + fn: ( + req: Object, + sender: { + url: string, + tab: { + id: number, + }, + } + ) => void + ) => void, + }, + }, +}; diff --git a/package.json b/package.json index 6cd8a520e020..daf94f6447ab 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "version": "4.0.0", "repository": "bvaughn/react-devtools-experimental", - "license": "BSD-3-Clause", + "license": "MIT", "private": true, "workspaces": [ "packages/*" @@ -32,6 +32,8 @@ ] }, "scripts": { + "build:core:backend": "cd ./packages/react-devtools-core && yarn build:backend", + "build:core:standalone": "cd ./packages/react-devtools-core && yarn build:standalone", "build:demo": "cd ./shells/dev && cross-env NODE_ENV=development cross-env TARGET=remote webpack --config webpack.config.js", "build:extension": "cross-env NODE_ENV=production yarn run build:extension:chrome && yarn run build:extension:firefox", "build:extension:dev": "cross-env NODE_ENV=development yarn run build:extension:chrome && yarn run build:extension:firefox", @@ -52,6 +54,9 @@ "prettier": "prettier --write '**/*.{js,json,css}'", "prettier:ci": "prettier --check '**/*.{js,json,css}'", "start": "cd ./shells/dev && cross-env NODE_ENV=development cross-env TARGET=local webpack-dev-server --open", + "start:core:backend": "cd ./packages/react-devtools-core && yarn start:backend", + "start:core:standalone": "cd ./packages/react-devtools-core && yarn start:standalone", + "start:electron": "cd ./packages/react-devtools && node bin.js", "start:prod": "cd ./shells/dev && cross-env NODE_ENV=production cross-env TARGET=local webpack-dev-server --open", "test": "jest", "test-debug": "node --inspect-brk node_modules/.bin/jest --runInBand", diff --git a/packages/react-devtools-core/README.md b/packages/react-devtools-core/README.md new file mode 100644 index 000000000000..7bb1a2e0b716 --- /dev/null +++ b/packages/react-devtools-core/README.md @@ -0,0 +1,44 @@ +# `react-devtools-core` + +A standalone React DevTools implementation. + +This is a low-level package. If you're looking for the Electron app you can run, **use `react-devtools` package instead.** + +## API + +### `react-devtools-core` + +This is similar requiring the `react-devtools` package, but provides several configurable options. Unlike `react-devtools`, requiring `react-devtools-core` doesn't connect immediately but instead exports a function: + +```js +const { connectToDevTools } = require("react-devtools-core"); +connectToDevTools({ + // Config options +}); + +``` + +Run `connectToDevTools()` in the same context as React to set up a connection to DevTools. +Be sure to run this function *before* importing e.g. `react`, `react-dom`, `react-native`. + +The `options` object may contain: +* `host: string` (defaults to "localhost") - Websocket will connect to this host. +* `port: number` (defaults to `8097`) - Websocket will connect to this port. +* `websocket: Websocket` - Custom websocked to use. Overrides `host` and `port` settings if provided. +* `resolveNativeStyle: (style: number) => ?Object` - Used by the React Native style plug-in. +* `isAppActive: () => boolean` - If provided, DevTools will poll this method and wait until it returns true before connecting to React. + +## `react-devtools-core/standalone` + +Renders the DevTools interface into a DOM node. + +```js +require("react-devtools-core/standalone") + .setContentDOMNode(document.getElementById("container")) + .setStatusListener(status => { + // This callback is optional... + }) + .startServer(port); +``` + +Reference the `react-devtools` package for a complete integration example. diff --git a/packages/react-devtools-core/backend.js b/packages/react-devtools-core/backend.js new file mode 100644 index 000000000000..2c2a32d45125 --- /dev/null +++ b/packages/react-devtools-core/backend.js @@ -0,0 +1 @@ +module.exports = require('./dist/backend'); diff --git a/packages/react-devtools-core/package.json b/packages/react-devtools-core/package.json new file mode 100644 index 000000000000..f560258e8f76 --- /dev/null +++ b/packages/react-devtools-core/package.json @@ -0,0 +1,31 @@ +{ + "name": "react-devtools-core", + "description": "Use react-devtools outside of the browser", + "version": "4.0.0", + "license": "MIT", + "main": "./dist/backend.js", + "repository": { + "url": "https://github.com/bvaughn/react-devtools-experimental.git", + "type": "git" + }, + "files": [ + "dist", + "backend.js", + "standalone.js" + ], + "scripts": { + "build": "yarn build:backend && yarn build:standalone", + "build:backend": "cross-env NODE_ENV=production webpack --config webpack.backend.js", + "build:standalone": "cross-env NODE_ENV=production webpack --config webpack.standalone.js", + "prepublish": "yarn run build", + "start:backend": "cross-env NODE_ENV=development webpack --config webpack.backend.js --watch", + "start:standalone": "cross-env NODE_ENV=development webpack --config webpack.standalone.js --watch" + }, + "dependencies": { + "shell-quote": "^1.6.1", + "ws": "^7" + }, + "devDependencies": { + "cross-env": "^3.1.4" + } +} diff --git a/packages/react-devtools-core/src/backend.js b/packages/react-devtools-core/src/backend.js new file mode 100644 index 000000000000..f76659180aa5 --- /dev/null +++ b/packages/react-devtools-core/src/backend.js @@ -0,0 +1,269 @@ +// @flow + +import Agent from 'src/backend/agent'; +import Bridge from 'src/bridge'; +import { installHook } from 'src/hook'; +import { initBackend } from 'src/backend'; +import { __DEBUG__ } from 'src/constants'; +import setupNativeStyleEditor from 'src/backend/NativeStyleEditor/setupNativeStyleEditor'; +import { getDefaultComponentFilters } from 'src/utils'; + +import type { ComponentFilter } from 'src/types'; +import type { DevToolsHook } from 'src/backend/types'; +import type { ResolveNativeStyle } from 'src/backend/NativeStyleEditor/setupNativeStyleEditor'; + +type ConnectOptions = { + host?: string, + nativeStyleEditorValidAttributes?: $ReadOnlyArray, + port?: number, + resolveRNStyle?: ResolveNativeStyle, + isAppActive?: () => boolean, + websocket?: ?WebSocket, +}; + +installHook(window); + +const hook: DevToolsHook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; + +let savedComponentFilters: Array = getDefaultComponentFilters(); + +function debug(methodName: string, ...args) { + if (__DEBUG__) { + console.log( + `%c[core/backend] %c${methodName}`, + 'color: teal; font-weight: bold;', + 'font-weight: bold;', + ...args + ); + } +} + +export function connectToDevTools(options: ?ConnectOptions) { + const { + host = 'localhost', + nativeStyleEditorValidAttributes, + port = 8097, + websocket, + resolveRNStyle = null, + isAppActive = () => true, + } = options || {}; + + let retryTimeoutID: TimeoutID | null = null; + + function scheduleRetry() { + if (retryTimeoutID === null) { + // Two seconds because RN had issues with quick retries. + retryTimeoutID = setTimeout(() => connectToDevTools(options), 2000); + } + } + + if (!isAppActive()) { + // If the app is in background, maybe retry later. + // Don't actually attempt to connect until we're in foreground. + scheduleRetry(); + return; + } + + let bridge: Bridge | null = null; + + const messageListeners = []; + const uri = 'ws://' + host + ':' + port; + + // If existing websocket is passed, use it. + // This is necessary to support our custom integrations. + // See D6251744. + const ws = websocket ? websocket : new window.WebSocket(uri); + ws.onclose = handleClose; + ws.onerror = handleFailed; + ws.onmessage = handleMessage; + ws.onopen = function() { + bridge = new Bridge({ + listen(fn) { + messageListeners.push(fn); + return () => { + const index = messageListeners.indexOf(fn); + if (index >= 0) { + messageListeners.splice(index, 1); + } + }; + }, + send(event: string, payload: any, transferable?: Array) { + if (ws.readyState === ws.OPEN) { + if (__DEBUG__) { + debug('wall.send()', event, payload); + } + + ws.send(JSON.stringify({ event, payload })); + } else { + if (__DEBUG__) { + debug( + 'wall.send()', + 'Shutting down bridge because of closed WebSocket connection' + ); + } + + if (bridge !== null) { + bridge.emit('shutdown'); + } + + scheduleRetry(); + } + }, + }); + bridge.addListener( + 'selectElement', + ({ id, rendererID }: {| id: number, rendererID: number |}) => { + const renderer = agent.rendererInterfaces[rendererID]; + if (renderer != null) { + // Send event for RN to highlight. + const nodes: ?Array = renderer.findNativeNodesForFiberID( + id + ); + if (nodes != null && nodes[0] != null) { + agent.emit('showNativeHighlight', nodes[0]); + } + } + } + ); + bridge.addListener( + 'updateComponentFilters', + (componentFilters: Array) => { + // Save filter changes in memory, in case DevTools is reloaded. + // In that case, the renderer will already be using the updated values. + // We'll lose these in between backend reloads but that can't be helped. + savedComponentFilters = componentFilters; + } + ); + + // The renderer interface doesn't read saved component filters directly, + // because they are generally stored in localStorage within the context of the extension. + // Because of this it relies on the extension to pass filters. + // In the case of the standalone DevTools being used with a website, + // saved filters are injected along with the backend script tag so we shouldn't override them here. + // This injection strategy doesn't work for React Native though. + // Ideally the backend would save the filters itself, but RN doesn't provide a sync storage solution. + // So for now we just fall back to using the default filters... + if (window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ == null) { + bridge.send('overrideComponentFilters', savedComponentFilters); + } + + // TODO (npm-packages) Warn if "isBackendStorageAPISupported" + const agent = new Agent(bridge); + agent.addListener('shutdown', () => { + // If we received 'shutdown' from `agent`, we assume the `bridge` is already shutting down, + // and that caused the 'shutdown' event on the `agent`, so we don't need to call `bridge.shutdown()` here. + hook.emit('shutdown'); + }); + + initBackend(hook, agent, window); + + // Setup React Native style editor if the environment supports it. + if (resolveRNStyle != null || hook.resolveRNStyle != null) { + setupNativeStyleEditor( + bridge, + agent, + ((resolveRNStyle || hook.resolveRNStyle: any): ResolveNativeStyle), + nativeStyleEditorValidAttributes || + hook.nativeStyleEditorValidAttributes || + null + ); + } else { + // Otherwise listen to detect if the environment later supports it. + // For example, Flipper does not eagerly inject these values. + // Instead it relies on the React Native Inspector to lazily inject them. + let lazyResolveRNStyle; + let lazyNativeStyleEditorValidAttributes; + + const initAfterTick = () => { + if (bridge !== null) { + setupNativeStyleEditor( + bridge, + agent, + lazyResolveRNStyle, + lazyNativeStyleEditorValidAttributes + ); + } + }; + + Object.defineProperty( + hook, + 'resolveRNStyle', + ({ + enumerable: false, + get() { + return lazyResolveRNStyle; + }, + set(value) { + lazyResolveRNStyle = value; + initAfterTick(); + }, + }: Object) + ); + Object.defineProperty( + hook, + 'nativeStyleEditorValidAttributes', + ({ + enumerable: false, + get() { + return lazyNativeStyleEditorValidAttributes; + }, + set(value) { + lazyNativeStyleEditorValidAttributes = value; + initAfterTick(); + }, + }: Object) + ); + } + }; + + function handleClose() { + if (__DEBUG__) { + debug('WebSocket.onclose'); + } + + if (bridge !== null) { + bridge.emit('shutdown'); + } + + scheduleRetry(); + } + + function handleFailed() { + if (__DEBUG__) { + debug('WebSocket.onerror'); + } + + scheduleRetry(); + } + + function handleMessage(event) { + let data; + try { + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + if (__DEBUG__) { + debug('WebSocket.onmessage', data); + } + } else { + throw Error(); + } + } catch (e) { + console.error( + '[React DevTools] Failed to parse JSON: ' + String(event.data) + ); + return; + } + messageListeners.forEach(fn => { + try { + fn(data); + } catch (error) { + // jsc doesn't play so well with tracebacks that go into eval'd code, + // so the stack trace here will stop at the `eval()` call. Getting the + // message that caused the error is the best we can do for now. + console.log('[React DevTools] Error calling listener', data); + console.log('error:', error); + throw error; + } + }); + } +} diff --git a/packages/react-devtools-core/src/launchEditor.js b/packages/react-devtools-core/src/launchEditor.js new file mode 100644 index 000000000000..94628be8582e --- /dev/null +++ b/packages/react-devtools-core/src/launchEditor.js @@ -0,0 +1,170 @@ +// @flow + +import { existsSync } from 'fs'; +import { basename, join, isAbsolute } from 'path'; +import { execSync, spawn } from 'child_process'; +import { parse } from 'shell-quote'; + +function isTerminalEditor(editor: string): boolean { + switch (editor) { + case 'vim': + case 'emacs': + case 'nano': + return true; + default: + return false; + } +} + +// Map from full process name to binary that starts the process +// We can't just re-use full process name, because it will spawn a new instance +// of the app every time +const COMMON_EDITORS = { + '/Applications/Atom.app/Contents/MacOS/Atom': 'atom', + '/Applications/Atom Beta.app/Contents/MacOS/Atom Beta': + '/Applications/Atom Beta.app/Contents/MacOS/Atom Beta', + '/Applications/Sublime Text.app/Contents/MacOS/Sublime Text': + '/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl', + '/Applications/Sublime Text 2.app/Contents/MacOS/Sublime Text 2': + '/Applications/Sublime Text 2.app/Contents/SharedSupport/bin/subl', + '/Applications/Visual Studio Code.app/Contents/MacOS/Electron': 'code', +}; + +function getArgumentsForLineNumber( + editor: string, + filePath: string, + lineNumber: number +): Array { + switch (basename(editor)) { + case 'vim': + case 'mvim': + return [filePath, '+' + lineNumber]; + case 'atom': + case 'Atom': + case 'Atom Beta': + case 'subl': + case 'sublime': + case 'wstorm': + case 'appcode': + case 'charm': + case 'idea': + return [filePath + ':' + lineNumber]; + case 'joe': + case 'emacs': + case 'emacsclient': + return ['+' + lineNumber, filePath]; + case 'rmate': + case 'mate': + case 'mine': + return ['--line', lineNumber + '', filePath]; + case 'code': + return ['-g', filePath + ':' + lineNumber]; + default: + // For all others, drop the lineNumber until we have + // a mapping above, since providing the lineNumber incorrectly + // can result in errors or confusing behavior. + return [filePath]; + } +} + +function guessEditor(): Array { + // Explicit config always wins + if (process.env.REACT_EDITOR) { + return parse(process.env.REACT_EDITOR); + } + + // Using `ps x` on OSX we can find out which editor is currently running. + // Potentially we could use similar technique for Windows and Linux + if (process.platform === 'darwin') { + try { + const output = execSync('ps x').toString(); + const processNames = Object.keys(COMMON_EDITORS); + for (let i = 0; i < processNames.length; i++) { + const processName = processNames[i]; + if (output.indexOf(processName) !== -1) { + return [COMMON_EDITORS[processName]]; + } + } + } catch (error) { + // Ignore... + } + } + + // Last resort, use old skool env vars + if (process.env.VISUAL) { + return [process.env.VISUAL]; + } else if (process.env.EDITOR) { + return [process.env.EDITOR]; + } + + return []; +} + +let childProcess = null; + +export default function launchEditor( + maybeRelativePath: string, + lineNumber: number, + absoluteProjectRoots: Array +) { + // We use relative paths at Facebook with deterministic builds. + // This is why our internal tooling calls React DevTools with absoluteProjectRoots. + // If the filename is absolute then we don't need to care about this. + let filePath; + if (isAbsolute(maybeRelativePath)) { + if (existsSync(maybeRelativePath)) { + filePath = maybeRelativePath; + } + } else { + for (let i = 0; i < absoluteProjectRoots.length; i++) { + const projectRoot = absoluteProjectRoots[i]; + const joinedPath = join(projectRoot, maybeRelativePath); + if (existsSync(joinedPath)) { + filePath = joinedPath; + break; + } + } + } + + if (!filePath) { + return; + } + + // Sanitize lineNumber to prevent malicious use on win32 + // via: https://github.com/nodejs/node/blob/c3bb4b1aa5e907d489619fb43d233c3336bfc03d/lib/child_process.js#L333 + if (lineNumber && isNaN(lineNumber)) { + return; + } + + let [editor, ...args] = guessEditor(); + if (!editor) { + return; + } + + if (lineNumber) { + args = args.concat(getArgumentsForLineNumber(editor, filePath, lineNumber)); + } else { + args.push(filePath); + } + + if (childProcess && isTerminalEditor(editor)) { + // There's an existing editor process already and it's attached + // to the terminal, so go kill it. Otherwise two separate editor + // instances attach to the stdin/stdout which gets confusing. + childProcess.kill('SIGKILL'); + } + + if (process.platform === 'win32') { + // On Windows, launch the editor in a shell because spawn can only + // launch .exe files. + childProcess = spawn('cmd.exe', ['/C', editor].concat(args), { + stdio: 'inherit', + }); + } else { + childProcess = spawn(editor, args, { stdio: 'inherit' }); + } + childProcess.on('error', function() {}); + childProcess.on('exit', function(errorCode) { + childProcess = null; + }); +} diff --git a/packages/react-devtools-core/src/standalone.js b/packages/react-devtools-core/src/standalone.js new file mode 100644 index 000000000000..d98956276e64 --- /dev/null +++ b/packages/react-devtools-core/src/standalone.js @@ -0,0 +1,286 @@ +// @flow + +import { createElement } from 'react'; +import { + // $FlowFixMe Flow does not yet know about flushSync() + flushSync, + // $FlowFixMe Flow does not yet know about createRoot() + unstable_createRoot as createRoot, +} from 'react-dom'; +import Bridge from 'src/bridge'; +import Store from 'src/devtools/store'; +import { getSavedComponentFilters } from 'src/utils'; +import { Server } from 'ws'; +import { existsSync, readFileSync } from 'fs'; +import { installHook } from 'src/hook'; +import DevTools from 'src/devtools/views/DevTools'; +import launchEditor from './launchEditor'; +import { __DEBUG__ } from 'src/constants'; + +import type { InspectedElement } from 'src/devtools/views/Components/types'; + +installHook(window); + +export type StatusListener = (message: string) => void; + +let node: HTMLElement = ((null: any): HTMLElement); +let nodeWaitingToConnectHTML: string = ''; +let projectRoots: Array = []; +let statusListener: StatusListener = (message: string) => {}; + +function setContentDOMNode(value: HTMLElement) { + node = value; + + // Save so we can restore the exact waiting message between sessions. + nodeWaitingToConnectHTML = node.innerHTML; + + return DevtoolsUI; +} + +function setProjectRoots(value: Array) { + projectRoots = value; +} + +function setStatusListener(value: StatusListener) { + statusListener = value; + return DevtoolsUI; +} + +let bridge: Bridge | null = null; +let store: Store | null = null; +let root = null; + +const log = (...args) => console.log('[React DevTools]', ...args); +log.warn = (...args) => console.warn('[React DevTools]', ...args); +log.error = (...args) => console.error('[React DevTools]', ...args); + +function debug(methodName: string, ...args) { + if (__DEBUG__) { + console.log( + `%c[core/standalone] %c${methodName}`, + 'color: teal; font-weight: bold;', + 'font-weight: bold;', + ...args + ); + } +} + +function safeUnmount() { + flushSync(() => { + if (root !== null) { + root.unmount(); + } + }); + root = null; +} + +function reload() { + safeUnmount(); + + node.innerHTML = ''; + + setTimeout(() => { + root = createRoot(node); + root.render( + createElement(DevTools, { + bridge: ((bridge: any): Bridge), + showTabBar: true, + store: ((store: any): Store), + viewElementSourceFunction, + viewElementSourceRequiresFileLocation: true, + }) + ); + }, 100); +} + +function viewElementSourceFunction( + id: number, + inspectedElement: InspectedElement +): void { + const { source } = inspectedElement; + if (source !== null) { + launchEditor(source.fileName, source.lineNumber, projectRoots); + } else { + log.error('Cannot inspect element', id); + } +} + +function onDisconnected() { + safeUnmount(); + + node.innerHTML = nodeWaitingToConnectHTML; +} + +function onError({ code, message }) { + safeUnmount(); + + if (code === 'EADDRINUSE') { + node.innerHTML = `

Another instance of DevTools is running

`; + } else { + node.innerHTML = `

Unknown error (${message})

`; + } +} + +function initialize(socket: WebSocket) { + const listeners = []; + socket.onmessage = event => { + let data; + try { + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + + if (__DEBUG__) { + debug('WebSocket.onmessage', data); + } + } else { + throw Error(); + } + } catch (e) { + log.error('Failed to parse JSON', event.data); + return; + } + listeners.forEach(fn => { + try { + fn(data); + } catch (error) { + log.error('Error calling listener', data); + throw error; + } + }); + }; + + bridge = new Bridge({ + listen(fn) { + listeners.push(fn); + return () => { + const index = listeners.indexOf(fn); + if (index >= 0) { + listeners.splice(index, 1); + } + }; + }, + send(event: string, payload: any, transferable?: Array) { + if (socket.readyState === socket.OPEN) { + socket.send(JSON.stringify({ event, payload })); + } + }, + }); + ((bridge: any): Bridge).addListener('shutdown', () => { + socket.close(); + }); + + store = new Store(bridge, { supportsNativeInspection: false }); + + log('Connected'); + reload(); +} + +let startServerTimeoutID: TimeoutID | null = null; + +function connectToSocket(socket: WebSocket) { + socket.onerror = err => { + onDisconnected(); + log.error('Error with websocket connection', err); + }; + socket.onclose = () => { + onDisconnected(); + log('Connection to RN closed'); + }; + initialize(socket); + + return { + close: function() { + onDisconnected(); + }, + }; +} + +function startServer(port?: number = 8097) { + const httpServer = require('http').createServer(); + const server = new Server({ server: httpServer }); + let connected: WebSocket | null = null; + server.on('connection', (socket: WebSocket) => { + if (connected !== null) { + connected.close(); + log.warn( + 'Only one connection allowed at a time.', + 'Closing the previous connection' + ); + } + connected = socket; + socket.onerror = error => { + connected = null; + onDisconnected(); + log.error('Error with websocket connection', error); + }; + socket.onclose = () => { + connected = null; + onDisconnected(); + log('Connection to RN closed'); + }; + initialize(socket); + }); + + server.on('error', event => { + onError(event); + log.error('Failed to start the DevTools server', event); + startServerTimeoutID = setTimeout(() => startServer(port), 1000); + }); + + httpServer.on('request', (request, response) => { + // NPM installs should read from node_modules, + // But local dev mode needs to use a relative path. + const basePath = existsSync('./node_modules/react-devtools-core') + ? 'node_modules/react-devtools-core' + : '../react-devtools-core'; + + // Serve a file that immediately sets up the connection. + const backendFile = readFileSync(`${basePath}/dist/backend.js`); + + // The renderer interface doesn't read saved component filters directly, + // because they are generally stored in localStorage within the context of the extension. + // Because of this it relies on the extension to pass filters, so include them wth the response here. + // This will ensure that saved filters are shared across different web pages. + const savedFiltersString = `window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = ${JSON.stringify( + getSavedComponentFilters() + )};`; + + response.end( + savedFiltersString + + '\n;' + + backendFile.toString() + + '\n;' + + 'ReactDevToolsBackend.connectToDevTools();' + ); + }); + + httpServer.on('error', event => { + onError(event); + statusListener('Failed to start the server.'); + startServerTimeoutID = setTimeout(() => startServer(port), 1000); + }); + + httpServer.listen(port, () => { + statusListener('The server is listening on the port ' + port + '.'); + }); + + return { + close: function() { + connected = null; + onDisconnected(); + clearTimeout(startServerTimeoutID); + server.close(); + httpServer.close(); + }, + }; +} + +const DevtoolsUI = { + connectToSocket, + setContentDOMNode, + setProjectRoots, + setStatusListener, + startServer, +}; + +export default DevtoolsUI; diff --git a/packages/react-devtools-core/standalone.js b/packages/react-devtools-core/standalone.js new file mode 100644 index 000000000000..fe55aa11d5d4 --- /dev/null +++ b/packages/react-devtools-core/standalone.js @@ -0,0 +1 @@ +module.exports = require('./dist/standalone'); diff --git a/packages/react-devtools-core/webpack.backend.js b/packages/react-devtools-core/webpack.backend.js new file mode 100644 index 000000000000..3103abdd6bcf --- /dev/null +++ b/packages/react-devtools-core/webpack.backend.js @@ -0,0 +1,53 @@ +const { resolve } = require('path'); +const { DefinePlugin } = require('webpack'); +const { getGitHubURL, getVersionString } = require('../../shells/utils'); + +const NODE_ENV = process.env.NODE_ENV; +if (!NODE_ENV) { + console.error('NODE_ENV not set'); + process.exit(1); +} + +const __DEV__ = NODE_ENV === 'development'; + +const GITHUB_URL = getGitHubURL(); +const DEVTOOLS_VERSION = getVersionString(); + +module.exports = { + mode: 'development', // TODO TESTING __DEV__ ? 'development' : 'production', + devtool: __DEV__ ? 'cheap-module-eval-source-map' : false, + entry: { + backend: './src/backend.js', + }, + output: { + path: __dirname + '/dist', + filename: '[name].js', + + // This name is important; standalone references it in order to connect. + library: 'ReactDevToolsBackend', + libraryTarget: 'umd', + }, + resolve: { + alias: { + src: resolve(__dirname, '../../src'), + }, + }, + plugins: [ + new DefinePlugin({ + __DEV__: true, + 'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`, + 'process.env.GITHUB_URL': `"${GITHUB_URL}"`, + }), + ], + module: { + rules: [ + { + test: /\.js$/, + loader: 'babel-loader', + options: { + configFile: resolve(__dirname, '../../babel.config.js'), + }, + }, + ], + }, +}; diff --git a/packages/react-devtools-core/webpack.standalone.js b/packages/react-devtools-core/webpack.standalone.js new file mode 100644 index 000000000000..b21dfe5180b2 --- /dev/null +++ b/packages/react-devtools-core/webpack.standalone.js @@ -0,0 +1,69 @@ +const { resolve } = require('path'); +const { DefinePlugin } = require('webpack'); +const { getGitHubURL, getVersionString } = require('../../shells/utils'); + +const NODE_ENV = process.env.NODE_ENV; +if (!NODE_ENV) { + console.error('NODE_ENV not set'); + process.exit(1); +} + +const __DEV__ = NODE_ENV === 'development'; + +const GITHUB_URL = getGitHubURL(); +const DEVTOOLS_VERSION = getVersionString(); + +module.exports = { + mode: __DEV__ ? 'development' : 'production', + devtool: __DEV__ ? 'cheap-module-eval-source-map' : false, + target: 'electron-main', + entry: { + standalone: './src/standalone.js', + }, + output: { + path: __dirname + '/dist', + filename: '[name].js', + library: '[name]', + libraryTarget: 'commonjs2', + }, + resolve: { + alias: { + src: resolve(__dirname, '../../src'), + }, + }, + plugins: [ + new DefinePlugin({ + __DEV__: false, + 'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`, + 'process.env.GITHUB_URL': `"${GITHUB_URL}"`, + 'process.env.NODE_ENV': `"${NODE_ENV}"`, + }), + ], + module: { + rules: [ + { + test: /\.js$/, + loader: 'babel-loader', + options: { + configFile: resolve(__dirname, '../../babel.config.js'), + }, + }, + { + test: /\.css$/, + use: [ + { + loader: 'style-loader', + }, + { + loader: 'css-loader', + options: { + sourceMap: true, + modules: true, + localIdentName: '[local]___[hash:base64:5]', + }, + }, + ], + }, + ], + }, +}; diff --git a/packages/react-devtools/README.md b/packages/react-devtools/README.md new file mode 100644 index 000000000000..2220a6661d5b --- /dev/null +++ b/packages/react-devtools/README.md @@ -0,0 +1,98 @@ +# `react-devtools` + +React DevTools is available as a built-in extension for Chrome and Firefox browsers. This package enables you to debug a React app elsewhere (e.g. a mobile browser, an embedded webview, Safari, inside an iframe). + +It works both with React DOM and React Native. + +Screenshot of React DevTools running with React Native + +## Installation +Install the `react-devtools` package. Because this is a development tool, a global install is often the most convenient: +```sh +# Yarn +yarn global add react-devtools + +# NPM +npm install -g react-devtools +``` + +If you prefer to avoid global installations, you can add `react-devtools` as a project dependency. With Yarn, you can do this by running: +```sh +yarn add --dev react-devtools +``` + +With NPM you can just use [NPX](https://www.npmjs.com/package/npx): +```sh +npx react-devtools +``` + +## Usage with React Native +Run `react-devtools` from the terminal to launch the standalone DevTools app: +```sh +react-devtools +``` + +If you're not in a simulator then you also need to run the following in a command prompt: +```sh +adb reverse tcp:8097 tcp:8097 +``` + +If you're using React Native 0.43 or higher, it should connect to your simulator within a few seconds. + +### Integration with React Native Inspector + +You can open the [in-app developer menu](https://facebook.github.io/react-native/docs/debugging.html#accessing-the-in-app-developer-menu) and choose "Show Inspector". It will bring up an overlay that lets you tap on any UI element and see information about it: + +![React Native Inspector](http://i.imgur.com/ReFhREb.gif) + +However, when `react-devtools` is running, Inspector will enter a special collapsed mode, and instead use the DevTools as primary UI. In this mode, clicking on something in the simulator will bring up the relevant components in the DevTools: + +![React DevTools Inspector Integration](http://i.imgur.com/wVgV9RP.gif) + +You can choose "Hide Inspector" in the same menu to exit this mode. + +### Inspecting Component Instances + +When debugging JavaScript in Chrome, you can inspect the props and state of the React components in the browser console. + +First, follow the [instructions for debugging in Chrome](https://facebook.github.io/react-native/docs/debugging.html#chrome-developer-tools) to open the Chrome console. + +Make sure that the dropdown in the top left corner of the Chrome console says `debuggerWorker.js`. **This step is essential.** + +Then select a React component in React DevTools. There is a search box at the top that helps you find one by name. As soon as you select it, it will be available as `$r` in the Chrome console, letting you inspect its props, state, and instance properties. + +![React DevTools Chrome Console Integration](http://i.imgur.com/Cpvhs8i.gif) + + +## Usage with React DOM + +The standalone shell can also be useful with React DOM (e.g. to debug apps in Safari or inside of an iframe). + +Run `react-devtools` from the terminal to launch the standalone DevTools app: +```sh +react-devtools +``` + +Add `` as the very first ` +``` + +This will ensure the developer tools are connected. **Don’t forget to remove it before deploying to production!** + +>If you install `react-devtools` as a project dependency, you may also replace the ` + + diff --git a/packages/react-devtools/app.js b/packages/react-devtools/app.js new file mode 100644 index 000000000000..c9b415ea4961 --- /dev/null +++ b/packages/react-devtools/app.js @@ -0,0 +1,50 @@ +// @flow + +const { app, BrowserWindow } = require('electron'); // Module to create native browser window. +const { join } = require('path'); + +const argv = require('minimist')(process.argv.slice(2)); +const projectRoots = argv._; +const defaultThemeName = argv.theme; + +let mainWindow = null; + +app.on('window-all-closed', function() { + app.quit(); +}); + +app.on('ready', function() { + // Create the browser window. + mainWindow = new BrowserWindow({ + width: 800, + height: 600, + icon: join(__dirname, 'icons/icon128.png'), + frame: false, + //titleBarStyle: 'customButtonsOnHover', + webPreferences: { + nodeIntegration: true, + }, + }); + + // and load the index.html of the app. + mainWindow.loadURL('file://' + __dirname + '/app.html'); // eslint-disable-line no-path-concat + mainWindow.webContents.executeJavaScript( + // We use this so that RN can keep relative JSX __source filenames + // but "click to open in editor" still works. js1 passes project roots + // as the argument to DevTools. + 'window.devtools.setProjectRoots(' + JSON.stringify(projectRoots) + ')' + ); + + if (argv.theme) { + mainWindow.webContents.executeJavaScript( + 'window.devtools.setDefaultThemeName(' + + JSON.stringify(defaultThemeName) + + ')' + ); + } + + // Emitted when the window is closed. + mainWindow.on('closed', function() { + mainWindow = null; + }); +}); diff --git a/packages/react-devtools/bin.js b/packages/react-devtools/bin.js new file mode 100755 index 000000000000..55fd22c22ff4 --- /dev/null +++ b/packages/react-devtools/bin.js @@ -0,0 +1,18 @@ +#!/usr/bin/env node + +// @flow + +const electron = require('electron'); +const spawn = require('cross-spawn'); +const argv = process.argv.slice(2); +const pkg = require('./package.json'); +const updateNotifier = require('update-notifier'); + +// notify if there's an update +updateNotifier({ pkg }).notify({ defer: false }); + +const result = spawn.sync(electron, [require.resolve('./app')].concat(argv), { + stdio: 'ignore', +}); + +process.exit(result.status); diff --git a/packages/react-devtools/icons/icon128.png b/packages/react-devtools/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..b9327946441f529cd5510f8ee14b788f0aaea1df GIT binary patch literal 4577 zcmV<75gzV|P)vv~kZc3+<7&OQJ0de1p`By!Ni z#KhV0r`7StD*hXFG@ocH{@4691*V=)Nsnezc= zZT{wNZ(2oX;1h@su%X+J)=|Rqk=ao1cin!pl0G*&8|Qb_PFf8BR(1Q(T3R*Q#ry5f z4~%wu&~EAg&~iFpkQcXj;)H0y!G|PYzwye8(aTehNAqF5d_G|Dq49bji~jfWi_r^@ zKGXrgmzJLX+31K>tD{3tTA}_N5WMn_$D@C5yD$D|Hg|Qv;zOc$tvDk(Y~?4C_40p< zp1c2!=-Dlk0|y530B;S#LfF9fUwTcnXz9`3e}iETk_d5V&r5@&2A%O7DP3vm~LKVhmwb-NIU7G;7^#BOL{H%mMb=&%&n~Q2c z=A28Sqs}@%*lPmVzy18G#sRQkY;5e?O}#l1b;8%Kk46_R3ig_Z9Dj0j~N>U$nW9Lv{nNk`Ib6mBeCsiM-NFn z_}CK?rwhj9`3wB*6YH-kr44wm_g!*L@;)wtiM-Eq_fPgjDZIu}XRV3eedhV9gUGu6 z_?0soH3f|U0Oo!4XLqSTLp7ozt40j6$&deVT_Nuq=|nTR0Hy)d1tzozubU+tLd;g{ zOaRUl>bflRA=D?o%V%ColQPT;AQ>4M@d5#$`rtR$n;{(%57VY@`GL2Id2`=?qdgDa zotUPqxjQg66VKecseJ%A?xHJuHW_Jp@;5gWek~Y*6v1Rm8}R+b6DLM{w{J}tVwog> z>2ff4{N#%0r5%4Ryhh%0-}Li|`AQRh0w5;Q)dV$?3Pu%b>a}WoOc?m^6`waVhT_m= z-y0w<|JF^WKY|%~f0pJ}+j4RSH8SM3uQhW(3&Q3Gka?V8MBl#h{bPY&FvY(-; zk+T3z+Uhvy5tWNapY=CH?ORmD87-=zeD-6gHGt*O*Fi{Jk+iF5k2;v|(gLmb0qRVK zAa&UBJT1c-T>eAdj&W-MkX6V1$b?Bk$VGVh{ir$6-b3mhT4@6Mkq-xS_yR7P_`>CV zk;JBA{(<%Z>L!=BpN$ThQpc?YK;2$289ra8${~=ex~D2N8TJhX0Qyb4tI z{{yg~3F-oY27rF%*fB>Ih7=>aWeSpw**Tuk@I>L=}9yv7w z7Jx!l0d*$8f%cGLka+x>3Wwp@6fvie)?|r@ zBQ%1Q0J2^$l%{JP0BD$~OD&?ZHHno(kSpngX3&x}J~@Q>=m0hb0F4P+dN`!r_s#Ov z?G$Q9zfA(5k<&o!Fr=yz=#o;YHE>n?r0G8oZB|1^w95;RZmZOhq~$#JKC-P{01&2R zna<|;oRrBXir>KhQLUC4;eA|3zeNa;$r=F_AFn;TYp%pQ?_5LEbgk0>5Sx_+01Qn7 zKuX6@n|Vy`C*@Tk^K3I(XIJuBo@u6OwD%yal>opI%&J)$EoFm|lseO~wQXp;X4ant ziKW?^t_^4m0G1yxtOqfc)@b@-XAlJez2F?+XBs>ZPZz zPE>wB)H)#_Nu5xICajrCVht3$2T~jgFam~t%@U{;05iGtHI>vl4w4LoNKf3b`U;Vt zyvm=lDqM>sSP#7jtWz`x&McqtW2!zoW#l2jTsr{r8X|p2rfFDVm5Uj5Hd~Si(hi3J zB<15S4Or!PndkY}T^kcHgnqWskc=nM@{yn3l^ntPKCJdYBIVY@TJr=#&jrw4Uklj) zWZr?1wjaKdJu{!+;$&~x7JkQ3@}vdI~-rVw&xjn9kj;o$(xh4zwydT z$#c7y%iaPunoQ|43{gv22>{r+6Tg0aqVn??FO8p1tiL+>S~_d@?R`Ev@>A#bARb3( z-}IiI?;row>(uwc?q`3$4r1LTrVbik*0Z*^V&-d(o7M;mSf;mcodW}31A3Us>$rRe zo}Wlc5@%IH$pby+l3!DOV-k+2Odb8=dKSI=jtxmG_4J>%<|1^_((xV@dC<|z z67`tg$m70rrTSif{n0wJ<=fI+!Wj7|l8)th7R;(+?OFCbaQEE*k3DZ)k2LdMPyYIb zTyukTKYQ<{L?aMUX#36gFiF1i4=QB>ea5DM+M?bn(vbD8c`vk#6lu7Qd3_xzOck$b z#q3BQYxmhm>It>iVsFxiT9V$*zE6<|fcIiutlz3}6zx-V@KhlUz~l@ZJ&hz3;^B%& zW8w$Iwwoy)y$zg)CIDbVwWD0r{!S0akH7!WwYMf~cR5+ub=E=mGS8Fu9F{}K-?MH2 za`5a19>(kHua)&-cdKLfPvS6Ko$tP22Wj$+a2V_r@m zjJIm*VLU3TDp~b-Bz!bmi!|h^YSvwL2B9Qxd>le6T-DYcVh%!H2Q%i8P9M`&i2`S5Su8>I7ZdMwtb>kQpHIc5+T6=GvIqQs;)&Mvj zR_GPFjanO@Vrh#!)o-o4DEXwceKlYmIen{;)n|^P7XUQXFQd{zlT6w4@xaThy9~)h z@H}VGXVxF*8+GL+EFq`_04s^%E2(8DkGVe5ANoAsr)MGW93ovBR)^qdTEOQd(g^^o zRJ+ci1;QNHM*2v9=wc3^*OL>ws^+Z<2(<9G5&*PmUZV2qBDn*gtn@cYE+*GKcBX6y zK9B<fJ5hpe};RmIJ3%+YS=B8c%#2ve4l3@j09Tu(CcmV zZY^NXL71!t0L!tFQ^7iz?*lBK%?{;tTOn2v=`Z?x#Wd#mW`$H`4KTBj8_fyVA@BNu zI6SH9C!{x^sumz3$xC=iEu^n=o0n87Dlo2N@?cSbb;ca>!Y7!jIe1ov&8iMc6Qf_M zR0{dDl%v-5WSK5Fz99EuzFi!9qxW-r9S8Fu)@2MhA7si%;8Z!}IQlbd)YpIPdzVI| z2QKQV7~;Ugs=*VIn>zU0qW2L}V44@ZTQ!PrS1G-29&wkA+j-C9F1pIpI=ps~G`)8o zfDD|qAux`TFr=iPsxneW9Yt)+a?5NV0DYo{O>6*S)%HXHtTV1j;)3 z5Xsp!d9zowj;lsdj(qvh_RCfFZV4Fbo;|Y69_#;0tSyUb;ZmJJKDM6?M{D@R&|1e0 zOre@MP24i3NjwtX?>1|zP$gRa!RFwc|hrhb1j|+ciqL4 zR&73UyrtekM$SU0!f|}%EH3u#0^pH8k z?j1b7ELn6~#ailLE1a}y@YuAnWzl7vFuI_$N+YXAQ7e1TngN7h{Oq;Sve>LpNb0(E zRH6(_P1V~`gEWWA1TEh@J)1NWr{9;9fn`gbHT9ncly%`Ge^d8qq17Pj>PnW2sx;Nj z^N<_xO%SnfA@4N^+8yj`vwV_IE^n6i(CXv!x^zQ4UtWv2MV9|kBqQnTQ}x9JSdxud zV3BygWgi%`LUHIp;}gl(*^T9piijgCHM}3*eVL0LeTWHRnrf!!aeDg$`oh5jG>rjULgJmM}ZW;?2wq^m^;TY*Ff$JXKED%!2I)4mp}jtSkg3Vk??3=RbIR(D42ZMyu5FR&piO{LyHI;* z0icCJb>_nf^{D3cQN8^DftLEM+w2S!05mVSF11LEZ-shjA}cxo1a+0HY2a$gF>C|? z{YZtvL>O=7{>9;Qg@|%7Mo6-TShflP*0r&r%z&tIR~xl3UcR;%z|pX;`S2lYoJ;GX z0L>8$A#JF9mklSomz8?f;wl&kG5_H0^{w&Ri+)ki&Y(ibTez@idIn6>ZVU5Oty~Pj z4P@4ccp5l3AJBT}`Q9`4Zpxj1fUok>j)zO-^Q|lI%G@9_?*YIAYtleVRo+bO9gvii_XMVpa_w`5v&(s92RDndx8tbMTnIab7by@?Rd@hh0N&p~1TB|p` zcU;%<>h7IR#|SCqMlRZ^mn>i5G&QLFBK?9ecJKDB=G{DE7HDfnV`N+2vjC;-+cnKA zR5er?YNRtL%|xwjI_(_MLM}E^O2d7pZdRF`kyhJQjbK)i<&x{PS#q_)x!T}c0RYJA zx33yygzTX&I{(0zniy>}Z~%bvMxhkRFSw?eRVC~=8X-iu8uGrSc@F@e3n`mqd`KY3 z^wB|J{=fhr9m5}Uj{o)OcHJxiy;ucE$^%opbk9uIvYGqm5oX%j9`n8ss{=qseWlTE3pxOF)B&KQHUnU)+lJQC6ad)L?L%v6 z3jmnx_Mx>jIl{kjD7ceP-F~!7{l~OSoWKjZ{b(H}JYT>+)6>&aV=={Fp4#n8E9m-I z`hPJ8fLRdS6Jr1~a7wp7LruSmnLk&2xgh>p^MEkASDO?qQ+R!

Inspectable elements

+ diff --git a/shells/dev/app/InspectableElements/SimpleValues.js b/shells/dev/app/InspectableElements/SimpleValues.js new file mode 100644 index 000000000000..43373d6095e7 --- /dev/null +++ b/shells/dev/app/InspectableElements/SimpleValues.js @@ -0,0 +1,23 @@ +// @flow + +import React from 'react'; + +export default function SimpleValues() { + return ( + + ); +} + +function ChildComponent(props: any) { + return null; +} diff --git a/shells/dev/src/devtools.js b/shells/dev/src/devtools.js index 0f35d726bdb0..6bf66cddcdd4 100644 --- a/shells/dev/src/devtools.js +++ b/shells/dev/src/devtools.js @@ -73,7 +73,6 @@ inject('dist/app.js', () => { batch.render( createElement(DevTools, { bridge, - browserName: 'Chrome', browserTheme: 'light', showTabBar: true, store, diff --git a/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap b/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap index 1c9be05a4fa3..88d8e2dcea28 100644 --- a/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap +++ b/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap @@ -473,3 +473,26 @@ exports[`InspectedElementContext should support custom objects with enumerable p "state": null } `; + +exports[`InspectedElementContext should support simple data types: 1: Initial inspection 1`] = ` +{ + "id": 2, + "owners": null, + "context": null, + "events": null, + "hooks": null, + "props": { + "boolean_false": false, + "boolean_true": true, + "infinity": null, + "integer_zero": 0, + "integer_one": 1, + "float": 1.23, + "string": "abc", + "string_empty": "", + "nan": null, + "value_null": null + }, + "state": null +} +`; diff --git a/src/__tests__/__snapshots__/profilingCache-test.js.snap b/src/__tests__/__snapshots__/profilingCache-test.js.snap index 85291acdac04..8485313a2989 100644 --- a/src/__tests__/__snapshots__/profilingCache-test.js.snap +++ b/src/__tests__/__snapshots__/profilingCache-test.js.snap @@ -1289,7 +1289,7 @@ Object { "interactionCommits": Map {}, "interactions": Map {}, "operations": Array [ - Uint32Array [ + Array [ 1, 1, 8, @@ -1325,7 +1325,7 @@ Object { 1, 14000, ], - Uint32Array [ + Array [ 1, 1, 0, @@ -1345,7 +1345,7 @@ Object { 1, 11000, ], - Uint32Array [ + Array [ 1, 1, 0, @@ -1452,7 +1452,7 @@ Object { "interactionCommits": Map {}, "interactions": Map {}, "operations": Array [ - Uint32Array [ + Array [ 1, 11, 15, @@ -1540,7 +1540,7 @@ Object { "interactionCommits": Map {}, "interactions": Map {}, "operations": Array [ - Uint32Array [ + Array [ 1, 6, 0, diff --git a/src/__tests__/inspectedElementContext-test.js b/src/__tests__/inspectedElementContext-test.js index 105a23135f28..7e9ee97dc628 100644 --- a/src/__tests__/inspectedElementContext-test.js +++ b/src/__tests__/inspectedElementContext-test.js @@ -255,6 +255,72 @@ describe('InspectedElementContext', () => { done(); }); + it('should support simple data types', async done => { + const Example = () => null; + + const container = document.createElement('div'); + await utils.actAsync(() => + ReactDOM.render( + , + container + ) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + let inspectedElement = null; + + function Suspender({ target }) { + const { getInspectedElement } = React.useContext(InspectedElementContext); + inspectedElement = getInspectedElement(id); + return null; + } + + await utils.actAsync( + () => + TestRenderer.create( + + + + + + ), + false + ); + + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot('1: Initial inspection'); + + const { props } = (inspectedElement: any); + expect(props.boolean_false).toBe(false); + expect(props.boolean_true).toBe(true); + expect(Number.isFinite(props.infinity)).toBe(false); + expect(props.integer_zero).toEqual(0); + expect(props.integer_one).toEqual(1); + expect(props.float).toEqual(1.23); + expect(props.string).toEqual('abc'); + expect(props.string_empty).toEqual(''); + expect(props.nan).toBeNaN(); + expect(props.value_null).toBeNull(); + expect(props.value_undefined).toBeUndefined(); + + done(); + }); + it('should support complex data types', async done => { const Example = () => null; diff --git a/src/__tests__/legacy/__snapshots__/inspectElement-test.js.snap b/src/__tests__/legacy/__snapshots__/inspectElement-test.js.snap index db3aec6f2988..40d3ab5ed91d 100644 --- a/src/__tests__/legacy/__snapshots__/inspectElement-test.js.snap +++ b/src/__tests__/legacy/__snapshots__/inspectElement-test.js.snap @@ -165,3 +165,30 @@ Object { }, } `; + +exports[`InspectedElementContext should support simple data types: 1: Initial inspection 1`] = ` +Object { + "id": 2, + "type": "full-data", + "value": { + "id": 2, + "owners": null, + "context": {}, + "events": null, + "hooks": null, + "props": { + "boolean_false": false, + "boolean_true": true, + "infinity": null, + "integer_zero": 0, + "integer_one": 1, + "float": 1.23, + "string": "abc", + "string_empty": "", + "nan": null, + "value_null": null + }, + "state": null +}, +} +`; diff --git a/src/__tests__/legacy/inspectElement-test.js b/src/__tests__/legacy/inspectElement-test.js index 23e384e52960..f71d22343f15 100644 --- a/src/__tests__/legacy/inspectElement-test.js +++ b/src/__tests__/legacy/inspectElement-test.js @@ -88,6 +88,49 @@ describe('InspectedElementContext', () => { done(); }); + it('should support simple data types', async done => { + const Example = () => null; + + act(() => + ReactDOM.render( + , + document.createElement('div') + ) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + const inspectedElement = await read(id); + + expect(inspectedElement).toMatchSnapshot('1: Initial inspection'); + + const { props } = inspectedElement.value; + expect(props.boolean_false).toBe(false); + expect(props.boolean_true).toBe(true); + expect(Number.isFinite(props.infinity)).toBe(false); + expect(props.integer_zero).toEqual(0); + expect(props.integer_one).toEqual(1); + expect(props.float).toEqual(1.23); + expect(props.string).toEqual('abc'); + expect(props.string_empty).toEqual(''); + expect(props.nan).toBeNaN(); + expect(props.value_null).toBeNull(); + expect(props.value_undefined).toBeUndefined(); + + done(); + }); + it('should support complex data types', async done => { const Example = () => null; diff --git a/src/backend/NativeStyleEditor/resolveBoxStyle.js b/src/backend/NativeStyleEditor/resolveBoxStyle.js new file mode 100644 index 000000000000..0609272dc905 --- /dev/null +++ b/src/backend/NativeStyleEditor/resolveBoxStyle.js @@ -0,0 +1,85 @@ +// @flow + +import type { BoxStyle } from './types'; + +/** + * This mirrors react-native/Libraries/Inspector/resolveBoxStyle.js (but without RTL support). + * + * Resolve a style property into it's component parts, e.g. + * + * resolveBoxStyle('margin', {margin: 5, marginBottom: 10}) + * -> {top: 5, left: 5, right: 5, bottom: 10} + */ +export default function resolveBoxStyle( + prefix: string, + style: Object +): BoxStyle | null { + let hasParts = false; + const result = { + bottom: 0, + left: 0, + right: 0, + top: 0, + }; + + const styleForAll = style[prefix]; + if (styleForAll != null) { + for (const key of Object.keys(result)) { + result[key] = styleForAll; + } + hasParts = true; + } + + const styleForHorizontal = style[prefix + 'Horizontal']; + if (styleForHorizontal != null) { + result.left = styleForHorizontal; + result.right = styleForHorizontal; + hasParts = true; + } else { + const styleForLeft = style[prefix + 'Left']; + if (styleForLeft != null) { + result.left = styleForLeft; + hasParts = true; + } + + const styleForRight = style[prefix + 'Right']; + if (styleForRight != null) { + result.right = styleForRight; + hasParts = true; + } + + const styleForEnd = style[prefix + 'End']; + if (styleForEnd != null) { + // TODO RTL support + result.right = styleForEnd; + hasParts = true; + } + const styleForStart = style[prefix + 'Start']; + if (styleForStart != null) { + // TODO RTL support + result.left = styleForStart; + hasParts = true; + } + } + + const styleForVertical = style[prefix + 'Vertical']; + if (styleForVertical != null) { + result.bottom = styleForVertical; + result.top = styleForVertical; + hasParts = true; + } else { + const styleForBottom = style[prefix + 'Bottom']; + if (styleForBottom != null) { + result.bottom = styleForBottom; + hasParts = true; + } + + const styleForTop = style[prefix + 'Top']; + if (styleForTop != null) { + result.top = styleForTop; + hasParts = true; + } + } + + return hasParts ? result : null; +} diff --git a/src/backend/NativeStyleEditor/setupNativeStyleEditor.js b/src/backend/NativeStyleEditor/setupNativeStyleEditor.js new file mode 100644 index 000000000000..5880bbac4d40 --- /dev/null +++ b/src/backend/NativeStyleEditor/setupNativeStyleEditor.js @@ -0,0 +1,315 @@ +// @flow + +import Agent from 'src/backend/agent'; +import Bridge from 'src/bridge'; +import resolveBoxStyle from './resolveBoxStyle'; + +import type { RendererID } from '../types'; +import type { StyleAndLayout } from './types'; + +export type ResolveNativeStyle = (stylesheetID: number) => ?Object; + +export default function setupNativeStyleEditor( + bridge: Bridge, + agent: Agent, + resolveNativeStyle: ResolveNativeStyle, + validAttributes?: $ReadOnlyArray | null +) { + bridge.addListener( + 'NativeStyleEditor_measure', + ({ id, rendererID }: {| id: number, rendererID: RendererID |}) => { + measureStyle(agent, bridge, resolveNativeStyle, id, rendererID); + } + ); + + bridge.addListener( + 'NativeStyleEditor_renameAttribute', + ({ + id, + rendererID, + oldName, + newName, + value, + }: {| + id: number, + rendererID: RendererID, + oldName: string, + newName: string, + value: string, + |}) => { + renameStyle(agent, id, rendererID, oldName, newName, value); + setTimeout(() => + measureStyle(agent, bridge, resolveNativeStyle, id, rendererID) + ); + } + ); + + bridge.addListener( + 'NativeStyleEditor_setValue', + ({ + id, + rendererID, + name, + value, + }: {| + id: number, + rendererID: number, + name: string, + value: string, + |}) => { + setStyle(agent, id, rendererID, name, value); + setTimeout(() => + measureStyle(agent, bridge, resolveNativeStyle, id, rendererID) + ); + } + ); + + bridge.send('isNativeStyleEditorSupported', { + isSupported: true, + validAttributes, + }); +} + +const EMPTY_BOX_STYLE = { + top: 0, + left: 0, + right: 0, + bottom: 0, +}; + +const componentIDToStyleOverrides: Map = new Map(); + +function measureStyle( + agent: Agent, + bridge: Bridge, + resolveNativeStyle: ResolveNativeStyle, + id: number, + rendererID: RendererID +) { + const data = agent.getInstanceAndStyle({ id, rendererID }); + if (!data || !data.style) { + bridge.send( + 'NativeStyleEditor_styleAndLayout', + ({ + id, + layout: null, + style: null, + }: StyleAndLayout) + ); + return; + } + + const { instance, style } = data; + + let resolvedStyle = resolveNativeStyle(style); + + // If it's a host component we edited before, amend styles. + const styleOverrides = componentIDToStyleOverrides.get(id); + if (styleOverrides != null) { + resolvedStyle = Object.assign({}, resolvedStyle, styleOverrides); + } + + if (!instance || typeof instance.measure !== 'function') { + bridge.send( + 'NativeStyleEditor_styleAndLayout', + ({ + id, + layout: null, + style: resolvedStyle || null, + }: StyleAndLayout) + ); + return; + } + + // $FlowFixMe the parameter types of an unknown function are unknown + instance.measure((x, y, width, height, left, top) => { + // RN Android sometimes returns undefined here. Don't send measurements in this case. + // https://github.com/jhen0409/react-native-debugger/issues/84#issuecomment-304611817 + if (typeof x !== 'number') { + bridge.send( + 'NativeStyleEditor_styleAndLayout', + ({ + id, + layout: null, + style: resolvedStyle || null, + }: StyleAndLayout) + ); + return; + } + const margin = resolveBoxStyle('margin', resolvedStyle) || EMPTY_BOX_STYLE; + const padding = + resolveBoxStyle('padding', resolvedStyle) || EMPTY_BOX_STYLE; + bridge.send( + 'NativeStyleEditor_styleAndLayout', + ({ + id, + layout: { + x, + y, + width, + height, + left, + top, + margin, + padding, + }, + style: resolvedStyle || null, + }: StyleAndLayout) + ); + }); +} + +function shallowClone(object: Object): Object { + const cloned = {}; + for (let n in object) { + cloned[n] = object[n]; + } + return cloned; +} + +function renameStyle( + agent: Agent, + id: number, + rendererID: RendererID, + oldName: string, + newName: string, + value: string +): void { + const data = agent.getInstanceAndStyle({ id, rendererID }); + if (!data || !data.style) { + return; + } + + const { instance, style } = data; + + const newStyle = newName + ? { [oldName]: undefined, [newName]: value } + : { [oldName]: undefined }; + + let customStyle; + + // TODO It would be nice if the renderer interface abstracted this away somehow. + if (instance !== null && typeof instance.setNativeProps === 'function') { + // In the case of a host component, we need to use setNativeProps(). + // Remember to "correct" resolved styles when we read them next time. + const styleOverrides = componentIDToStyleOverrides.get(id); + if (!styleOverrides) { + componentIDToStyleOverrides.set(id, newStyle); + } else { + Object.assign(styleOverrides, newStyle); + } + // TODO Fabric does not support setNativeProps; chat with Sebastian or Eli + instance.setNativeProps({ style: newStyle }); + } else if (Array.isArray(style)) { + const lastIndex = style.length - 1; + if ( + typeof style[lastIndex] === 'object' && + !Array.isArray(style[lastIndex]) + ) { + customStyle = shallowClone(style[lastIndex]); + delete customStyle[oldName]; + if (newName) { + customStyle[newName] = value; + } else { + customStyle[oldName] = undefined; + } + + agent.overrideProps({ + id, + rendererID, + path: ['style', lastIndex], + value: customStyle, + }); + } else { + agent.overrideProps({ + id, + rendererID, + path: ['style'], + value: style.concat([newStyle]), + }); + } + } else if (typeof style === 'object') { + customStyle = shallowClone(style); + delete customStyle[oldName]; + if (newName) { + customStyle[newName] = value; + } else { + customStyle[oldName] = undefined; + } + + agent.overrideProps({ + id, + rendererID, + path: ['style'], + value: customStyle, + }); + } else { + agent.overrideProps({ + id, + rendererID, + path: ['style'], + value: [style, newStyle], + }); + } + + agent.emit('hideNativeHighlight'); +} + +function setStyle( + agent: Agent, + id: number, + rendererID: RendererID, + name: string, + value: string +) { + const data = agent.getInstanceAndStyle({ id, rendererID }); + if (!data || !data.style) { + return; + } + + const { instance, style } = data; + const newStyle = { [name]: value }; + + // TODO It would be nice if the renderer interface abstracted this away somehow. + if (instance !== null && typeof instance.setNativeProps === 'function') { + // In the case of a host component, we need to use setNativeProps(). + // Remember to "correct" resolved styles when we read them next time. + const styleOverrides = componentIDToStyleOverrides.get(id); + if (!styleOverrides) { + componentIDToStyleOverrides.set(id, newStyle); + } else { + Object.assign(styleOverrides, newStyle); + } + // TODO Fabric does not support setNativeProps; chat with Sebastian or Eli + instance.setNativeProps({ style: newStyle }); + } else if (Array.isArray(style)) { + const lastLength = style.length - 1; + if ( + typeof style[lastLength] === 'object' && + !Array.isArray(style[lastLength]) + ) { + agent.overrideProps({ + id, + rendererID, + path: ['style', lastLength, name], + value, + }); + } else { + agent.overrideProps({ + id, + rendererID, + path: ['style'], + value: style.concat([newStyle]), + }); + } + } else { + agent.overrideProps({ + id, + rendererID, + path: ['style'], + value: [style, newStyle], + }); + } + + agent.emit('hideNativeHighlight'); +} diff --git a/src/backend/NativeStyleEditor/types.js b/src/backend/NativeStyleEditor/types.js new file mode 100644 index 000000000000..f7040cb8d304 --- /dev/null +++ b/src/backend/NativeStyleEditor/types.js @@ -0,0 +1,27 @@ +// @flow + +export type BoxStyle = $ReadOnly<{| + bottom: number, + left: number, + right: number, + top: number, +|}>; + +export type Layout = {| + x: number, + y: number, + width: number, + height: number, + left: number, + top: number, + margin: BoxStyle, + padding: BoxStyle, +|}; + +export type Style = Object; + +export type StyleAndLayout = {| + id: number, + style: Style | null, + layout: Layout | null, +|}; diff --git a/src/backend/agent.js b/src/backend/agent.js index 75ca51b2016c..abdd5ed37f6f 100644 --- a/src/backend/agent.js +++ b/src/backend/agent.js @@ -1,7 +1,6 @@ // @flow import EventEmitter from 'events'; -import memoize from 'memoize-one'; import throttle from 'lodash.throttle'; import Bridge from 'src/bridge'; import { @@ -15,9 +14,11 @@ import { sessionStorageRemoveItem, sessionStorageSetItem, } from 'src/storage'; -import { hideOverlay, showOverlay } from './views/Highlighter'; +import setupHighlighter from './views/Highlighter'; import type { + InstanceAndStyle, + NativeType, OwnersList, PathFrame, PathMatch, @@ -75,6 +76,8 @@ type PersistedSelection = {| |}; export default class Agent extends EventEmitter<{| + hideNativeHighlight: [], + showNativeHighlight: [NativeType], shutdown: [], |}> { _bridge: Bridge; @@ -110,13 +113,8 @@ export default class Agent extends EventEmitter<{| this._bridge = bridge; bridge.addListener('captureScreenshot', this.captureScreenshot); - bridge.addListener( - 'clearHighlightedElementInDOM', - this.clearHighlightedElementInDOM - ); bridge.addListener('getProfilingData', this.getProfilingData); bridge.addListener('getProfilingStatus', this.getProfilingStatus); - bridge.addListener('highlightElementInDOM', this.highlightElementInDOM); bridge.addListener('getOwnersList', this.getOwnersList); bridge.addListener('inspectElement', this.inspectElement); bridge.addListener('logElementToConsole', this.logElementToConsole); @@ -128,9 +126,7 @@ export default class Agent extends EventEmitter<{| bridge.addListener('reloadAndProfile', this.reloadAndProfile); bridge.addListener('screenshotCaptured', this.screenshotCaptured); bridge.addListener('selectElement', this.selectElement); - bridge.addListener('startInspectingDOM', this.startInspectingDOM); bridge.addListener('startProfiling', this.startProfiling); - bridge.addListener('stopInspectingDOM', this.stopInspectingDOM); bridge.addListener('stopProfiling', this.stopProfiling); bridge.addListener( 'syncSelectionFromNativeElementsPanel', @@ -152,6 +148,12 @@ export default class Agent extends EventEmitter<{| isBackendStorageAPISupported = true; } catch (error) {} bridge.send('isBackendStorageAPISupported', isBackendStorageAPISupported); + + setupHighlighter(bridge, this); + } + + get rendererInterfaces(): { [key: RendererID]: RendererInterface } { + return this._rendererInterfaces; } captureScreenshot = ({ @@ -164,6 +166,18 @@ export default class Agent extends EventEmitter<{| this._bridge.send('captureScreenshot', { commitIndex, rootID }); }; + getInstanceAndStyle({ + id, + rendererID, + }: ElementAndRendererID): InstanceAndStyle | null { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}"`); + return null; + } + return renderer.getInstanceAndStyle(id); + } + getIDForNode(node: Object): number | null { for (let rendererID in this._rendererInterfaces) { // A renderer will throw if it can't find a fiber for the specified node. @@ -190,54 +204,6 @@ export default class Agent extends EventEmitter<{| this._bridge.send('profilingStatus', this._isProfiling); }; - clearHighlightedElementInDOM = () => { - hideOverlay(); - }; - - highlightElementInDOM = ({ - displayName, - hideAfterTimeout, - id, - openNativeElementsPanel, - rendererID, - scrollIntoView, - }: { - displayName: string, - hideAfterTimeout: boolean, - id: number, - openNativeElementsPanel: boolean, - rendererID: number, - scrollIntoView: boolean, - }) => { - const renderer = this._rendererInterfaces[rendererID]; - if (renderer == null) { - console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); - } - - let nodes: ?Array = null; - if (renderer !== null) { - nodes = ((renderer.findNativeNodesForFiberID( - id - ): any): ?Array); - } - - if (nodes != null && nodes[0] != null) { - const node = nodes[0]; - if (scrollIntoView && typeof node.scrollIntoView === 'function') { - // If the node isn't visible show it before highlighting it. - // We may want to reconsider this; it might be a little disruptive. - node.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - } - showOverlay(nodes, displayName, hideAfterTimeout); - if (openNativeElementsPanel) { - window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = node; - this._bridge.send('syncSelectionToNativeElementsPanel'); - } - } else { - hideOverlay(); - } - }; - getOwnersList = ({ id, rendererID }: ElementAndRendererID) => { const renderer = this._rendererInterfaces[rendererID]; if (renderer == null) { @@ -373,6 +339,13 @@ export default class Agent extends EventEmitter<{| } }; + selectNode(target: Object): void { + const id = this.getIDForNode(target); + if (id !== null) { + this._bridge.send('selectFiber', id); + } + } + setRendererInterface( rendererID: RendererID, rendererInterface: RendererInterface @@ -397,28 +370,14 @@ export default class Agent extends EventEmitter<{| if (target == null) { return; } - const id = this.getIDForNode(target); - if (id !== null) { - this._bridge.send('selectFiber', id); - } + this.selectNode(target); }; shutdown = () => { // Clean up the overlay if visible, and associated events. - this.stopInspectingDOM(); this.emit('shutdown'); }; - startInspectingDOM = () => { - window.addEventListener('click', this._onClick, 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 = (recordChangeDescriptions: boolean) => { this._recordChangeDescriptions = recordChangeDescriptions; this._isProfiling = true; @@ -431,18 +390,6 @@ export default class Agent extends EventEmitter<{| this._bridge.send('profilingStatus', this._isProfiling); }; - stopInspectingDOM = () => { - hideOverlay(); - - window.removeEventListener('click', this._onClick, 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 = () => { this._isProfiling = false; this._recordChangeDescriptions = false; @@ -473,7 +420,7 @@ export default class Agent extends EventEmitter<{| } }; - onHookOperations = (operations: Uint32Array) => { + onHookOperations = (operations: Array) => { if (__DEBUG__) { debug('onHookOperations', operations); } @@ -505,80 +452,32 @@ export default class Agent extends EventEmitter<{| if (this._persistedSelection.rendererID === rendererID) { // Check if we can select a deeper match for the persisted selection. const renderer = this._rendererInterfaces[rendererID]; - const prevMatch = this._persistedSelectionMatch; - const nextMatch = renderer.getBestMatchForTrackedPath(); - this._persistedSelectionMatch = nextMatch; - const prevMatchID = prevMatch !== null ? prevMatch.id : null; - const nextMatchID = nextMatch !== null ? nextMatch.id : null; - if (prevMatchID !== nextMatchID) { - if (nextMatchID !== null) { - // We moved forward, unlocking a deeper node. - this._bridge.send('selectFiber', nextMatchID); + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}"`); + } else { + const prevMatch = this._persistedSelectionMatch; + const nextMatch = renderer.getBestMatchForTrackedPath(); + this._persistedSelectionMatch = nextMatch; + const prevMatchID = prevMatch !== null ? prevMatch.id : null; + const nextMatchID = nextMatch !== null ? nextMatch.id : null; + if (prevMatchID !== nextMatchID) { + if (nextMatchID !== null) { + // We moved forward, unlocking a deeper node. + this._bridge.send('selectFiber', nextMatchID); + } + } + if (nextMatch !== null && nextMatch.isFullMatch) { + // We've just unlocked the innermost selected node. + // There's no point tracking it further. + this._persistedSelection = null; + this._persistedSelectionMatch = null; + renderer.setTrackedPath(null); } - } - if (nextMatch !== null && nextMatch.isFullMatch) { - // We've just unlocked the innermost selected node. - // There's no point tracking it further. - this._persistedSelection = null; - this._persistedSelectionMatch = null; - renderer.setTrackedPath(null); } } } }; - _onClick = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - - this.stopInspectingDOM(); - - this._bridge.send('stopInspectingDOM', true); - }; - - _onMouseEvent = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - }; - - _onPointerDown = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - - this._selectFiberForNode(((event.target: any): HTMLElement)); - }; - - _onPointerOver = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - - const target = ((event.target: any): HTMLElement); - - // Don't pass the name explicitly. - // It will be inferred from DOM tag and Fiber owner. - showOverlay([target], null, false); - - this._selectFiberForNode(target); - }; - - _onPointerUp = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - }; - - _selectFiberForNode = throttle( - memoize((node: HTMLElement) => { - const id = this.getIDForNode(node); - if (id !== null) { - this._bridge.send('selectFiber', id); - } - }), - 200, - // Don't change the selection in the very first 200ms - // because those are usually unintentional as you lift the cursor. - { leading: false } - ); - _throttledPersistSelection = throttle((rendererID: number, id: number) => { // This is throttled, so both renderer and selected ID // might not be available by the time we read them. diff --git a/src/backend/legacy/renderer.js b/src/backend/legacy/renderer.js index 2e22bc73375c..96517385bc9b 100644 --- a/src/backend/legacy/renderer.js +++ b/src/backend/legacy/renderer.js @@ -22,6 +22,7 @@ import type { DevToolsHook, GetFiberIDForNative, InspectedElementPayload, + InstanceAndStyle, NativeType, PathFrame, PathMatch, @@ -456,7 +457,7 @@ export function attach( const numUnmountIDs = pendingUnmountedIDs.length + (pendingUnmountedRootID === null ? 0 : 1); - const operations = new Uint32Array( + const operations = new Array( // Identify which renderer this update is coming from. 2 + // [rendererID, rootFiberID] // How big is the string table? @@ -482,7 +483,10 @@ export function attach( operations[i++] = pendingStringTableLength; pendingStringTable.forEach((value, key) => { operations[i++] = key.length; - operations.set(utfEncodeString(key), i); + const encodedKey = utfEncodeString(key); + for (let j = 0; j < encodedKey.length; j++) { + operations[i + j] = encodedKey[j]; + } i += key.length; }); @@ -503,7 +507,9 @@ export function attach( } // Fill in the rest of the operations. - operations.set(pendingOperations, i); + for (let j = 0; j < pendingOperations.length; j++) { + operations[i + j] = pendingOperations[j]; + } i += pendingOperations.length; if (__DEBUG__) { @@ -582,6 +588,27 @@ export function attach( }; } + // Fast path props lookup for React Native style editor. + function getInstanceAndStyle(id: number): InstanceAndStyle { + let instance = null; + let style = null; + + const internalInstance = idToInternalInstanceMap.get(id); + if (internalInstance != null) { + instance = internalInstance._instance || null; + + const element = internalInstance._currentElement; + if (element != null && element.props != null) { + style = element.props.style || null; + } + } + + return { + instance, + style, + }; + } + function inspectElement( id: number, path?: Array @@ -879,6 +906,7 @@ export function attach( flushInitialOperations, getBestMatchForTrackedPath, getFiberIDForNative: getInternalIDForNative, + getInstanceAndStyle, findNativeNodesForFiberID: (id: number) => { const nativeNode = findNativeNodeForInternalID(id); return nativeNode == null ? null : [nativeNode]; diff --git a/src/backend/renderer.js b/src/backend/renderer.js index a021dd74ad8a..dbb91eec68cd 100644 --- a/src/backend/renderer.js +++ b/src/backend/renderer.js @@ -47,6 +47,7 @@ import type { Fiber, InspectedElement, InspectedElementPayload, + InstanceAndStyle, Owner, PathFrame, PathMatch, @@ -856,7 +857,7 @@ export function attach( let pendingOperations: Array = []; let pendingRealUnmountedIDs: Array = []; let pendingSimulatedUnmountedIDs: Array = []; - let pendingOperationsQueue: Array | null = []; + let pendingOperationsQueue: Array> | null = []; let pendingStringTable: Map = new Map(); let pendingStringTableLength: number = 0; let pendingUnmountedRootID: number | null = null; @@ -893,7 +894,7 @@ export function attach( pendingSimulatedUnmountedIDs.length + (pendingUnmountedRootID === null ? 0 : 1); - const operations = new Uint32Array( + const operations = new Array( // Identify which renderer this update is coming from. 2 + // [rendererID, rootFiberID] // How big is the string table? @@ -919,7 +920,10 @@ export function attach( operations[i++] = pendingStringTableLength; pendingStringTable.forEach((value, key) => { operations[i++] = key.length; - operations.set(utfEncodeString(key), i); + const encodedKey = utfEncodeString(key); + for (let j = 0; j < encodedKey.length; j++) { + operations[i + j] = encodedKey[j]; + } i += key.length; }); @@ -939,7 +943,9 @@ export function attach( // 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. - operations.set(pendingSimulatedUnmountedIDs, i); + for (let j = 0; j < pendingSimulatedUnmountedIDs.length; j++) { + operations[i + j] = pendingSimulatedUnmountedIDs[j]; + } i += pendingSimulatedUnmountedIDs.length; // The root ID should always be unmounted last. if (pendingUnmountedRootID !== null) { @@ -948,7 +954,10 @@ export function attach( } } // Fill in the rest of the operations. - operations.set(pendingOperations, i); + for (let j = 0; j < pendingOperations.length; j++) { + operations[i + j] = pendingOperations[j]; + } + i += pendingOperations.length; // Let the frontend know about tree operations. // The first value in this array will identify which root it corresponds to, @@ -1969,6 +1978,22 @@ export function attach( return owners; } + // Fast path props lookup for React Native style editor. + // Could use inspectElementRaw() but that would require shallow rendering hooks components, + // and could also mess with memoization. + function getInstanceAndStyle(id: number): InstanceAndStyle { + let instance = null; + let style = null; + + let fiber = findCurrentFiberUsingSlowPathById(id); + if (fiber !== null) { + instance = fiber.stateNode; + style = fiber.memoizedProps.style; + } + + return { instance, style }; + } + function inspectElementRaw(id: number): InspectedElement | null { let fiber = findCurrentFiberUsingSlowPathById(id); if (fiber == null) { @@ -2837,6 +2862,7 @@ export function attach( flushInitialOperations, getBestMatchForTrackedPath, getFiberIDForNative, + getInstanceAndStyle, getOwnersList, getPathForElement, getProfilingData, diff --git a/src/backend/types.js b/src/backend/types.js index b9e3a8521e29..59791db472ae 100644 --- a/src/backend/types.js +++ b/src/backend/types.js @@ -2,6 +2,7 @@ import type { ComponentFilter, ElementType } from 'src/types'; import type { Interaction } from 'src/devtools/views/Profiler/types'; +import type { ResolveNativeStyle } from 'src/backend/NativeStyleEditor/setupNativeStyleEditor'; type BundleType = | 0 // PROD @@ -249,12 +250,18 @@ export type InspectedElementPayload = | InspectElementNoChange | InspectElementNotFound; +export type InstanceAndStyle = {| + instance: Object | null, + style: Object | null, +|}; + export type RendererInterface = { cleanup: () => void, findNativeNodesForFiberID: FindNativeNodesForFiberID, flushInitialOperations: () => void, getBestMatchForTrackedPath: () => PathMatch | null, getFiberIDForNative: GetFiberIDForNative, + getInstanceAndStyle(id: number): InstanceAndStyle, getProfilingData(): ProfilingDataBackend, getOwnersList: (id: number) => Array | null, getPathForElement: (id: number) => Array | null, @@ -299,6 +306,10 @@ export type DevToolsHook = { reactDevtoolsAgent?: ?Object, sub: (event: string, handler: Handler) => () => void, + // Used by react-native-web and Flipper/Inspector + resolveRNStyle?: ResolveNativeStyle, + nativeStyleEditorValidAttributes?: $ReadOnlyArray, + // React uses these methods. checkDCE: (fn: Function) => void, onCommitFiberUnmount: (rendererID: RendererID, fiber: Object) => void, diff --git a/src/backend/views/Highlighter.js b/src/backend/views/Highlighter/Highlighter.js similarity index 86% rename from src/backend/views/Highlighter.js rename to src/backend/views/Highlighter/Highlighter.js index 9445d838e3de..1d838f8c124f 100644 --- a/src/backend/views/Highlighter.js +++ b/src/backend/views/Highlighter/Highlighter.js @@ -21,6 +21,11 @@ export function showOverlay( componentName: string | null, hideAfterTimeout: boolean ) { + // TODO (npm-packages) Detect RN and support it somehow + if (window.document == null) { + return; + } + if (timeoutID !== null) { clearTimeout(timeoutID); } diff --git a/src/backend/views/Overlay.js b/src/backend/views/Highlighter/Overlay.js similarity index 100% rename from src/backend/views/Overlay.js rename to src/backend/views/Highlighter/Overlay.js diff --git a/src/backend/views/Highlighter/index.js b/src/backend/views/Highlighter/index.js new file mode 100644 index 000000000000..3096b6fab3eb --- /dev/null +++ b/src/backend/views/Highlighter/index.js @@ -0,0 +1,142 @@ +// @flow + +import memoize from 'memoize-one'; +import throttle from 'lodash.throttle'; +import Bridge from 'src/bridge'; +import Agent from 'src/backend/agent'; +import { hideOverlay, showOverlay } from './Highlighter'; + +export default function setup(bridge: Bridge, agent: Agent): void { + bridge.addListener( + 'clearNativeElementHighlight', + clearNativeElementHighlight + ); + bridge.addListener('highlightNativeElement', highlightNativeElement); + bridge.addListener('shutdown', stopInspectingNative); + bridge.addListener('startInspectingNative', startInspectingNative); + bridge.addListener('stopInspectingNative', stopInspectingNative); + + function startInspectingNative() { + window.addEventListener('click', onClick, true); + window.addEventListener('mousedown', onMouseEvent, true); + window.addEventListener('mouseover', onMouseEvent, true); + window.addEventListener('mouseup', onMouseEvent, true); + window.addEventListener('pointerdown', onPointerDown, true); + window.addEventListener('pointerover', onPointerOver, true); + window.addEventListener('pointerup', onPointerUp, true); + } + + function stopInspectingNative() { + hideOverlay(); + + window.removeEventListener('click', onClick, true); + window.removeEventListener('mousedown', onMouseEvent, true); + window.removeEventListener('mouseover', onMouseEvent, true); + window.removeEventListener('mouseup', onMouseEvent, true); + window.removeEventListener('pointerdown', onPointerDown, true); + window.removeEventListener('pointerover', onPointerOver, true); + window.removeEventListener('pointerup', onPointerUp, true); + } + + function clearNativeElementHighlight() { + hideOverlay(); + } + + function highlightNativeElement({ + displayName, + hideAfterTimeout, + id, + openNativeElementsPanel, + rendererID, + scrollIntoView, + }: { + displayName: string, + hideAfterTimeout: boolean, + id: number, + openNativeElementsPanel: boolean, + rendererID: number, + scrollIntoView: boolean, + }) { + const renderer = agent.rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + } + + let nodes: ?Array = null; + if (renderer !== null) { + nodes = ((renderer.findNativeNodesForFiberID( + id + ): any): ?Array); + } + + if (nodes != null && nodes[0] != null) { + const node = nodes[0]; + if (scrollIntoView && typeof node.scrollIntoView === 'function') { + // If the node isn't visible show it before highlighting it. + // We may want to reconsider this; it might be a little disruptive. + node.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } + + showOverlay(nodes, displayName, hideAfterTimeout); + + if (openNativeElementsPanel) { + window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = node; + bridge.send('syncSelectionToNativeElementsPanel'); + } + } else { + hideOverlay(); + } + } + + function onClick(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + stopInspectingNative(); + + bridge.send('stopInspectingNative', true); + } + + function onMouseEvent(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + } + + function onPointerDown(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + selectFiberForNode(((event.target: any): HTMLElement)); + } + + function onPointerOver(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + const target = ((event.target: any): HTMLElement); + + // Don't pass the name explicitly. + // It will be inferred from DOM tag and Fiber owner. + showOverlay([target], null, false); + + selectFiberForNode(target); + } + + function onPointerUp(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + } + + const selectFiberForNode = throttle( + memoize((node: HTMLElement) => { + const id = agent.getIDForNode(node); + if (id !== null) { + bridge.send('selectFiber', id); + } + }), + 200, + // Don't change the selection in the very first 200ms + // because those are usually unintentional as you lift the cursor. + { leading: false } + ); +} diff --git a/src/bridge.js b/src/bridge.js index 1d08474e3983..d9db3b6318f1 100644 --- a/src/bridge.js +++ b/src/bridge.js @@ -9,6 +9,7 @@ import type { ProfilingDataBackend, RendererID, } from 'src/backend/types'; +import type { StyleAndLayout as StyleAndLayoutPayload } from 'src/backend/NativeStyleEditor/types'; const BATCH_DURATION = 100; @@ -48,20 +49,34 @@ type InspectElementParams = {| path?: Array, |}; +type NativeStyleEditor_RenameAttributeParams = {| + ...ElementAndRendererID, + oldName: string, + newName: string, + value: string, +|}; + +type NativeStyleEditor_SetValueParams = {| + ...ElementAndRendererID, + name: string, + value: string, +|}; + export default class Bridge extends EventEmitter<{| captureScreenshot: [{| commitIndex: number, rootID: number |}], - clearHighlightedElementInDOM: [], + clearNativeElementHighlight: [], getOwnersList: [ElementAndRendererID], getProfilingData: [{| rendererID: RendererID |}], getProfilingStatus: [], - highlightElementInDOM: [HighlightElementInDOM], + highlightNativeElement: [HighlightElementInDOM], init: [], inspectElement: [InspectElementParams], inspectedElement: [InspectedElementPayload], isBackendStorageAPISupported: [boolean], logElementToConsole: [ElementAndRendererID], - operations: [Uint32Array], + operations: [Array], ownersList: [OwnersList], + overrideComponentFilters: [Array], overrideContext: [OverrideValue], overrideHookState: [OverrideHookState], overrideProps: [OverrideValue], @@ -77,14 +92,23 @@ export default class Bridge extends EventEmitter<{| selectElement: [ElementAndRendererID], selectFiber: [number], shutdown: [], - startInspectingDOM: [], + startInspectingNative: [], startProfiling: [boolean], - stopInspectingDOM: [boolean], + stopInspectingNative: [boolean], stopProfiling: [], syncSelectionFromNativeElementsPanel: [], syncSelectionToNativeElementsPanel: [], updateComponentFilters: [Array], viewElementSource: [ElementAndRendererID], + + // React Native style editor plug-in. + isNativeStyleEditorSupported: [ + {| isSupported: boolean, validAttributes: $ReadOnlyArray |}, + ], + NativeStyleEditor_measure: [ElementAndRendererID], + NativeStyleEditor_renameAttribute: [NativeStyleEditor_RenameAttributeParams], + NativeStyleEditor_setValue: [NativeStyleEditor_SetValueParams], + NativeStyleEditor_styleAndLayout: [StyleAndLayoutPayload], |}> { _isShutdown: boolean = false; _messageQueue: Array = []; diff --git a/src/devtools/ProfilerStore.js b/src/devtools/ProfilerStore.js index 1f20fa2a051c..bf2e8544a3f6 100644 --- a/src/devtools/ProfilerStore.js +++ b/src/devtools/ProfilerStore.js @@ -60,7 +60,7 @@ export default class ProfilerStore extends EventEmitter<{| // // This map is only updated while profiling is in progress; // Upon completion, it is converted into the exportable ProfilingDataFrontend format. - _inProgressOperationsByRootID: Map> = new Map(); + _inProgressOperationsByRootID: Map>> = new Map(); // Map of root (id) to a Map of screenshots by commit ID. // Stores screenshots for each commit (when profiling). @@ -228,12 +228,7 @@ export default class ProfilerStore extends EventEmitter<{| } }; - 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)); - } - + onBridgeOperations = (operations: Array) => { // The first two values are always rendererID and rootID const rendererID = operations[0]; const rootID = operations[1]; diff --git a/src/devtools/store.js b/src/devtools/store.js index 76ef1544e8ff..2c778a0ccd00 100644 --- a/src/devtools/store.js +++ b/src/devtools/store.js @@ -46,6 +46,7 @@ const LOCAL_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY = type Config = {| isProfiling?: boolean, supportsCaptureScreenshots?: boolean, + supportsNativeInspection?: boolean, supportsReloadAndProfile?: boolean, supportsProfiling?: boolean, |}; @@ -66,6 +67,7 @@ export default class Store extends EventEmitter<{| mutated: [[Array, Map]], recordChangeDescriptions: [], roots: [], + supportsNativeStyleEditor: [], supportsProfiling: [], supportsReloadAndProfile: [], |}> { @@ -86,10 +88,15 @@ export default class Store extends EventEmitter<{| // The InspectedElementContext also relies on this mutability for its WeakMap usage. _idToElement: Map = new Map(); + // Should the React Native style editor panel be shown? + _isNativeStyleEditorSupported: boolean = false; + // Can the backend use the Storage API (e.g. localStorage)? // If not, features like reload-and-profile will not work correctly and must be disabled. _isBackendStorageAPISupported: boolean = false; + _nativeStyleEditorValidAttributes: $ReadOnlyArray | null = null; + // Map of element (id) to the set of elements (ids) it owns. // This map enables getOwnersListForElement() to avoid traversing the entire tree. _ownersMap: Map> = new Map(); @@ -114,6 +121,7 @@ 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; + _supportsNativeInspection: boolean = false; _supportsProfiling: boolean = false; _supportsReloadAndProfile: boolean = false; @@ -145,6 +153,7 @@ export default class Store extends EventEmitter<{| const { supportsCaptureScreenshots, + supportsNativeInspection, supportsProfiling, supportsReloadAndProfile, } = config; @@ -153,6 +162,7 @@ export default class Store extends EventEmitter<{| this._captureScreenshots = localStorageGetItem(LOCAL_STORAGE_CAPTURE_SCREENSHOTS_KEY) === 'true'; } + this._supportsNativeInspection = supportsNativeInspection !== false; if (supportsProfiling) { this._supportsProfiling = true; } @@ -163,11 +173,19 @@ export default class Store extends EventEmitter<{| this._bridge = bridge; bridge.addListener('operations', this.onBridgeOperations); + bridge.addListener( + 'overrideComponentFilters', + this.onBridgeOverrideComponentFilters + ); bridge.addListener('shutdown', this.onBridgeShutdown); bridge.addListener( 'isBackendStorageAPISupported', this.onBridgeStorageSupported ); + bridge.addListener( + 'isNativeStyleEditorSupported', + this.onBridgeNativeStyleEditorSupported + ); this._profilerStore = new ProfilerStore(bridge, this, isProfiling); } @@ -283,6 +301,10 @@ export default class Store extends EventEmitter<{| return this._hasOwnerMetadata; } + get nativeStyleEditorValidAttributes(): $ReadOnlyArray | null { + return this._nativeStyleEditorValidAttributes; + } + get numElements(): number { return this._weightAcrossRoots; } @@ -321,6 +343,14 @@ export default class Store extends EventEmitter<{| return this._supportsCaptureScreenshots; } + get supportsNativeInspection(): boolean { + return this._supportsNativeInspection; + } + + get supportsNativeStyleEditor(): boolean { + return this._isNativeStyleEditorSupported; + } + get supportsProfiling(): boolean { return this._supportsProfiling; } @@ -666,12 +696,20 @@ export default class Store extends EventEmitter<{| } }; - 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)); - } + onBridgeNativeStyleEditorSupported = ({ + isSupported, + validAttributes, + }: {| + isSupported: boolean, + validAttributes: $ReadOnlyArray, + |}) => { + this._isNativeStyleEditorSupported = isSupported; + this._nativeStyleEditorValidAttributes = validAttributes || null; + + this.emit('supportsNativeStyleEditor'); + }; + onBridgeOperations = (operations: Array) => { if (__DEBUG__) { console.groupCollapsed('onBridgeOperations'); debug('onBridgeOperations', operations.join(',')); @@ -965,6 +1003,19 @@ export default class Store extends EventEmitter<{| this.emit('mutated', [addedElementIDs, removedElementIDs]); }; + // Certain backends save filters on a per-domain basis. + // In order to prevent filter preferences and applied filters from being out of sync, + // this message enables the backend to override the frontend's current ("saved") filters. + // This action should also override the saved filters too, + // else reloading the frontend without reloading the backend would leave things out of sync. + onBridgeOverrideComponentFilters = ( + componentFilters: Array + ) => { + this._componentFilters = componentFilters; + + saveComponentFilters(componentFilters); + }; + onBridgeShutdown = () => { if (__DEBUG__) { debug('onBridgeShutdown', 'unsubscribing from Bridge'); diff --git a/src/devtools/views/Components/Components.css b/src/devtools/views/Components/Components.css index 0cecdd905616..f37b3eaa73d3 100644 --- a/src/devtools/views/Components/Components.css +++ b/src/devtools/views/Components/Components.css @@ -24,6 +24,14 @@ .Components { flex-direction: column; } + + .TreeWrapper { + flex: 0 0 50%; + } + + .SelectedElementWrapper { + flex: 0 0 50%; + } } .Loading { diff --git a/src/devtools/views/Components/Components.js b/src/devtools/views/Components/Components.js index 997d051678c4..2623041e0c4a 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 { NativeStyleContextController } from './NativeStyleEditor/context'; import { OwnersListContextController } from './OwnersListContext'; import portaledContent from '../portaledContent'; import { ModalDialog } from '../ModalDialog'; @@ -23,9 +24,11 @@ function Components(_: {||}) {
- }> - - + + }> + + +
diff --git a/src/devtools/views/Components/Element.css b/src/devtools/views/Components/Element.css index bd5a42c7be8a..97baeb888c93 100644 --- a/src/devtools/views/Components/Element.css +++ b/src/devtools/views/Components/Element.css @@ -22,10 +22,6 @@ user-select: none; } -.Bracket { - color: var(--color-jsx-arrow-brackets); -} - .ScrollAnchor { height: 100%; width: 0; @@ -42,7 +38,6 @@ --color-component-badge-background-inverted ); --color-component-badge-count: var(--color-component-badge-count-inverted); - --color-jsx-arrow-brackets: var(--color-jsx-arrow-brackets-inverted); --color-attribute-name: var(--color-attribute-name-inverted); --color-attribute-value: var(--color-attribute-value-inverted); --color-expand-collapse-toggle: var(--color-component-name-inverted); diff --git a/src/devtools/views/Components/Element.js b/src/devtools/views/Components/Element.js index ca3a64ceddbb..67cc721e6cd2 100644 --- a/src/devtools/views/Components/Element.js +++ b/src/devtools/views/Components/Element.js @@ -119,7 +119,6 @@ export default function ElementView({ data, index, style }: Props) { {ownerID === null ? ( ) : null} - < {key && ( @@ -129,7 +128,6 @@ export default function ElementView({ data, index, style }: Props) { )} - > { - const onStopInspectingDOM = () => setIsInspecting(false); - bridge.addListener('stopInspectingDOM', onStopInspectingDOM); + const onStopInspectingNative = () => setIsInspecting(false); + bridge.addListener('stopInspectingNative', onStopInspectingNative); return () => - bridge.removeListener('stopInspectingDOM', onStopInspectingDOM); + bridge.removeListener('stopInspectingNative', onStopInspectingNative); }, [bridge]); return ( diff --git a/src/devtools/views/Components/NativeStyleEditor/AutoSizeInput.css b/src/devtools/views/Components/NativeStyleEditor/AutoSizeInput.css new file mode 100644 index 000000000000..d231c8b8af02 --- /dev/null +++ b/src/devtools/views/Components/NativeStyleEditor/AutoSizeInput.css @@ -0,0 +1,26 @@ +.Input { + width: 0; + min-width: 0.5rem; + flex: 1 1 auto; + border: none; + background: transparent; + outline: none; + padding: 0; + border: none; + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-normal); +} + +.Input:focus { + border-color: var(--color-border); +} + +.HiddenDiv { + position: absolute; + top: 0; + left: 0; + visibility: hidden; + height: 0; + overflow: scroll; + white-space: pre; +} diff --git a/src/devtools/views/Components/NativeStyleEditor/AutoSizeInput.js b/src/devtools/views/Components/NativeStyleEditor/AutoSizeInput.js new file mode 100644 index 000000000000..f2eb4f4cb6b0 --- /dev/null +++ b/src/devtools/views/Components/NativeStyleEditor/AutoSizeInput.js @@ -0,0 +1,93 @@ +// @flow + +import React, { Fragment, useLayoutEffect, useRef } from 'react'; +import styles from './AutoSizeInput.css'; + +type Props = { + className?: string, + onFocus?: (event: FocusEvent) => void, + placeholder?: string, + value: any, +}; + +export default function AutoSizeInput({ + className, + onFocus, + placeholder, + value, + ...rest +}: Props) { + const hiddenDivRef = useRef(null); + const inputRef = useRef(null); + + const onFocusWrapper = event => { + if (inputRef.current !== null) { + inputRef.current.selectionStart = 0; + inputRef.current.selectionEnd = value.length; + } + + if (typeof onFocus === 'function') { + onFocus(event); + } + }; + + // Copy text stlyes from to hidden sizing
+ useLayoutEffect(() => { + if ( + typeof window.getComputedStyle !== 'function' || + inputRef.current === null + ) { + return; + } + + const inputStyle = window.getComputedStyle(inputRef.current); + if (!inputStyle) { + return; + } + + if (hiddenDivRef.current !== null) { + const divStyle = hiddenDivRef.current.style; + divStyle.border = inputStyle.border; + divStyle.fontFamily = inputStyle.fontFamily; + divStyle.fontSize = inputStyle.fontSize; + divStyle.fontStyle = inputStyle.fontStyle; + divStyle.fontWeight = inputStyle.fontWeight; + divStyle.letterSpacing = inputStyle.letterSpacing; + divStyle.padding = inputStyle.padding; + } + }, []); + + // Resize input any time text changes + useLayoutEffect(() => { + if (hiddenDivRef.current === null) { + return; + } + + const scrollWidth = hiddenDivRef.current.getBoundingClientRect().width; + if (!scrollWidth) { + return; + } + + if (inputRef.current !== null) { + inputRef.current.style.width = `${scrollWidth}px`; + } + }, [value]); + + const isEmpty = value === '' || value === '""'; + + return ( + + +
+ {isEmpty ? placeholder : value} +
+
+ ); +} diff --git a/src/devtools/views/Components/NativeStyleEditor/LayoutViewer.css b/src/devtools/views/Components/NativeStyleEditor/LayoutViewer.css new file mode 100644 index 000000000000..d21b49197a19 --- /dev/null +++ b/src/devtools/views/Components/NativeStyleEditor/LayoutViewer.css @@ -0,0 +1,50 @@ +.LayoutViewer { + padding: 0.25rem; + border-top: 1px solid var(--color-border); + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-small); +} + +.Header { + font-family: var(--font-family-sans); +} + +.DashedBox, +.SolidBox { + flex: 1 1 auto; + display: flex; + flex-direction: column; + align-items: center; + border-width: 1px; + border-color: var(--color-dim); + padding: 0.25rem; + margin: 0.25rem; +} +.DashedBox { + border-style: dashed; +} +.SolidBox { + border-style: solid; +} + +.LabelRow { + width: 100%; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.Label { + position: absolute; + left: 0.5rem; + flex: 1 0 100px; + color: var(--color-attribute-name); +} + +.BoxRow { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; +} diff --git a/src/devtools/views/Components/NativeStyleEditor/LayoutViewer.js b/src/devtools/views/Components/NativeStyleEditor/LayoutViewer.js new file mode 100644 index 000000000000..1a4724801144 --- /dev/null +++ b/src/devtools/views/Components/NativeStyleEditor/LayoutViewer.js @@ -0,0 +1,64 @@ +// @flow + +import React from 'react'; +import styles from './LayoutViewer.css'; + +import type { Layout } from './types'; + +type Props = {| + id: number, + layout: Layout, +|}; + +export default function LayoutViewer({ id, layout }: Props) { + const { height, margin, padding, y, width, x } = layout; + + return ( +
+
layout
+
+
+ + + +
+ +
+ + +
+
+ + + +
+ +
+ + +
+
+ {format(width)} x {format(height)} ({format(x)}, {format(y)}) +
+
+ + +
+ + +
+ +
+ +
+
+ ); +} + +function format(number: number): string | number { + if (Math.round(number) === number) { + return number; + } else { + return number.toFixed(1); + } +} diff --git a/src/devtools/views/Components/NativeStyleEditor/StyleEditor.css b/src/devtools/views/Components/NativeStyleEditor/StyleEditor.css new file mode 100644 index 000000000000..a097665581b7 --- /dev/null +++ b/src/devtools/views/Components/NativeStyleEditor/StyleEditor.css @@ -0,0 +1,52 @@ +.StyleEditor { + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-normal); + padding: 0.25rem; + border-top: 1px solid var(--color-border); +} + +.HeaderRow { + display: flex; + align-items: center; +} + +.Header { + flex: 1 1; +} + +.Brackets { + font-family: var(--font-family-sans); + font-size: var(--font-size-sans-small); +} + +.Row { + white-space: nowrap; + padding-left: 1rem; + display: flex; + align-items: center; +} + +.Invalid { + background-color: var(--color-background-invalid); + color: var(--color-text-invalid); + + --color-border: var(--color-text-invalid); +} +.Attribute { + color: var(--color-attribute-name); +} + +.Value { + color: var(--color-attribute-value); +} + +.Input { + flex: 0 1 auto; +} + +.Empty { + color: var(--color-dimmer); + font-style: italic; + user-select: none; + padding-left: 1rem; +} diff --git a/src/devtools/views/Components/NativeStyleEditor/StyleEditor.js b/src/devtools/views/Components/NativeStyleEditor/StyleEditor.js new file mode 100644 index 000000000000..570f473355ee --- /dev/null +++ b/src/devtools/views/Components/NativeStyleEditor/StyleEditor.js @@ -0,0 +1,278 @@ +// @flow + +import React, { useContext, useMemo, useRef, useState } from 'react'; +import { unstable_batchedUpdates as batchedUpdates } from 'react-dom'; +import { copy } from 'clipboard-js'; +import { BridgeContext, StoreContext } from 'src/devtools/views/context'; +import Button from '../../Button'; +import ButtonIcon from '../../ButtonIcon'; +import { serializeDataForCopy } from '../../utils'; +import AutoSizeInput from './AutoSizeInput'; +import styles from './StyleEditor.css'; + +import type { Style } from './types'; + +type Props = {| + id: number, + style: Style, +|}; + +type ChangeAttributeFn = (oldName: string, newName: string, value: any) => void; +type ChangeValueFn = (name: string, value: any) => void; + +export default function StyleEditor({ id, style }: Props) { + const bridge = useContext(BridgeContext); + const store = useContext(StoreContext); + + const changeAttribute = (oldName: string, newName: string, value: any) => { + bridge.send('NativeStyleEditor_renameAttribute', { + id, + rendererID: store.getRendererIDForElement(id), + oldName, + newName, + value, + }); + }; + + const changeValue = (name: string, value: any) => { + bridge.send('NativeStyleEditor_setValue', { + id, + rendererID: store.getRendererIDForElement(id), + name, + value, + }); + }; + + const keys = useMemo(() => Array.from(Object.keys(style)), [style]); + + const handleCopy = () => copy(serializeDataForCopy(style)); + + return ( +
+
+
+
{'style {'}
+
+ +
+ {keys.length > 0 && + keys.map(attribute => ( + + ))} + +
{'}'}
+
+ ); +} + +type NewRowProps = {| + changeAttribute: ChangeAttributeFn, + changeValue: ChangeValueFn, + validAttributes: $ReadOnlyArray | null, +|}; + +function NewRow({ + changeAttribute, + changeValue, + validAttributes, +}: NewRowProps) { + const [key, setKey] = useState(0); + const reset = () => setKey(key + 1); + + const newAttributeRef = useRef(''); + + const changeAttributeWrapper = ( + oldAttribute: string, + newAttribute: string, + value: any + ) => { + // Ignore attribute changes until a value has been specified + newAttributeRef.current = newAttribute; + }; + + const changeValueWrapper = (attribute: string, value: any) => { + // Blur events should reset/cancel if there's no value or no attribute + if (newAttributeRef.current !== '') { + if (value !== '') { + changeValue(newAttributeRef.current, value); + } + reset(); + } + }; + + return ( + + ); +} + +type RowProps = {| + attribute: string, + attributePlaceholder?: string, + changeAttribute: ChangeAttributeFn, + changeValue: ChangeValueFn, + validAttributes: $ReadOnlyArray | null, + value: any, + valuePlaceholder?: string, +|}; + +function Row({ + attribute, + attributePlaceholder, + changeAttribute, + changeValue, + validAttributes, + value, + valuePlaceholder, +}: RowProps) { + // TODO (RN style editor) Use @reach/combobox to auto-complete attributes. + // The list of valid attributes would need to be injected by RN backend, + // which would need to require them from ReactNativeViewViewConfig "validAttributes.style" keys. + // This would need to degrade gracefully for react-native-web, + // althoguh we could let it also inject a custom set of whitelisted attributes. + + const [localAttribute, setLocalAttribute] = useState(attribute); + const [localValue, setLocalValue] = useState(JSON.stringify(value)); + const [isAttributeValid, setIsAttributeValid] = useState(true); + const [isValueValid, setIsValueValid] = useState(true); + + const validateAndSetLocalAttribute = attribute => { + const isValid = + attribute === '' || + validAttributes === null || + validAttributes.indexOf(attribute) >= 0; + + batchedUpdates(() => { + setLocalAttribute(attribute); + setIsAttributeValid(isValid); + }); + }; + + const validateAndSetLocalValue = value => { + let isValid = false; + try { + JSON.parse(value); + isValid = true; + } catch (error) {} + + batchedUpdates(() => { + setLocalValue(value); + setIsValueValid(isValid); + }); + }; + + const resetAttribute = () => { + setLocalAttribute(attribute); + }; + + const resetValue = () => { + setLocalValue(value); + }; + + const submitValueChange = () => { + if (isValueValid) { + const parsedLocalValue = JSON.parse(localValue); + if (value !== parsedLocalValue) { + changeValue(attribute, parsedLocalValue); + } + } + }; + + const submitAttributeChange = () => { + if (isAttributeValid && attribute !== localAttribute) { + changeAttribute(attribute, localAttribute, value); + } + }; + + return ( +
+ + :  + + ; +
+ ); +} + +type FieldProps = {| + className: string, + onChange: (value: any) => void, + onReset: () => void, + onSubmit: () => void, + placeholder?: string, + value: any, +|}; + +function Field({ + className, + onChange, + onReset, + onSubmit, + placeholder, + value, +}: FieldProps) { + const onKeyDown = event => { + switch (event.key) { + case 'Enter': + onSubmit(); + break; + case 'Escape': + onReset(); + break; + case 'ArrowDown': + case 'ArrowLeft': + case 'ArrowRight': + case 'ArrowUp': + event.stopPropagation(); + break; + default: + break; + } + }; + + return ( + onChange(event.target.value)} + onKeyDown={onKeyDown} + placeholder={placeholder} + value={value} + /> + ); +} diff --git a/src/devtools/views/Components/NativeStyleEditor/context.js b/src/devtools/views/Components/NativeStyleEditor/context.js new file mode 100644 index 000000000000..927a4d4151d5 --- /dev/null +++ b/src/devtools/views/Components/NativeStyleEditor/context.js @@ -0,0 +1,187 @@ +// @flow + +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { unstable_batchedUpdates as batchedUpdates } from 'react-dom'; +import { createResource } from 'src/devtools/cache'; +import { BridgeContext, StoreContext } from 'src/devtools/views/context'; +import { TreeStateContext } from '../TreeContext'; + +import type { StyleAndLayout as StyleAndLayoutBackend } from 'src/backend/NativeStyleEditor/types'; +import type { StyleAndLayout as StyleAndLayoutFrontend } from './types'; +import type { Element } from 'src/devtools/views/Components/types'; +import type { Resource, Thenable } from 'src/devtools/cache'; + +export type GetStyleAndLayout = (id: number) => StyleAndLayoutFrontend | null; + +type Context = {| + getStyleAndLayout: GetStyleAndLayout, +|}; + +const NativeStyleContext = createContext(((null: any): Context)); +NativeStyleContext.displayName = 'NativeStyleContext'; + +type ResolveFn = (styleAndLayout: StyleAndLayoutFrontend) => void; +type InProgressRequest = {| + promise: Thenable, + resolveFn: ResolveFn, +|}; + +const inProgressRequests: WeakMap = new WeakMap(); +const resource: Resource< + Element, + Element, + StyleAndLayoutFrontend +> = 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 NativeStyleContextController({ children }: Props) { + const bridge = useContext(BridgeContext); + const store = useContext(StoreContext); + + const getStyleAndLayout = useCallback( + (id: number) => { + const element = store.getElementByID(id); + if (element !== null) { + return resource.read(element); + } else { + return null; + } + }, + [store] + ); + + // It's very important that this context consumes selectedElementID and not NativeStyleID. + // Otherwise the effect that sends the "inspect" message across the bridge- + // would itself be blocked by the same render that suspends (waiting for the data). + const { selectedElementID } = useContext(TreeStateContext); + + const [ + currentStyleAndLayout, + setCurrentStyleAndLayout, + ] = useState(null); + + // This effect handler invalidates the suspense cache and schedules rendering updates with React. + useEffect(() => { + const onStyleAndLayout = ({ id, layout, style }: StyleAndLayoutBackend) => { + let element = store.getElementByID(id); + if (element !== null) { + const styleAndLayout: StyleAndLayoutFrontend = { + layout, + style, + }; + const request = inProgressRequests.get(element); + if (request != null) { + inProgressRequests.delete(element); + batchedUpdates(() => { + request.resolveFn(styleAndLayout); + setCurrentStyleAndLayout(styleAndLayout); + }); + } else { + resource.write(element, styleAndLayout); + + // Schedule update with React if the curently-selected element has been invalidated. + if (id === selectedElementID) { + setCurrentStyleAndLayout(styleAndLayout); + } + } + } + }; + + bridge.addListener('NativeStyleEditor_styleAndLayout', onStyleAndLayout); + return () => + bridge.removeListener( + 'NativeStyleEditor_styleAndLayout', + onStyleAndLayout + ); + }, [bridge, currentStyleAndLayout, selectedElementID, store]); + + // This effect handler polls for updates on the currently selected element. + useEffect(() => { + if (selectedElementID === null) { + return () => {}; + } + + const rendererID = store.getRendererIDForElement(selectedElementID); + + let timeoutID: TimeoutID | null = null; + + const sendRequest = () => { + timeoutID = null; + + bridge.send('NativeStyleEditor_measure', { + id: selectedElementID, + rendererID, + }); + }; + + // Send the initial measurement request. + // We'll poll for an update in the response handler below. + sendRequest(); + + const onStyleAndLayout = ({ id }: StyleAndLayoutBackend) => { + // If this is the element we requested, wait a little bit and then ask for another update. + if (id === selectedElementID) { + if (timeoutID !== null) { + clearTimeout(timeoutID); + } + timeoutID = setTimeout(sendRequest, 1000); + } + }; + + bridge.addListener('NativeStyleEditor_styleAndLayout', onStyleAndLayout); + + return () => { + bridge.removeListener( + 'NativeStyleEditor_styleAndLayout', + onStyleAndLayout + ); + + if (timeoutID !== null) { + clearTimeout(timeoutID); + } + }; + }, [bridge, selectedElementID, store]); + + const value = useMemo( + () => ({ getStyleAndLayout }), + // NativeStyle is used to invalidate the cache and schedule an update with React. + // eslint-disable-next-line react-hooks/exhaustive-deps + [currentStyleAndLayout, getStyleAndLayout] + ); + + return ( + + {children} + + ); +} + +export { NativeStyleContext, NativeStyleContextController }; diff --git a/src/devtools/views/Components/NativeStyleEditor/index.js b/src/devtools/views/Components/NativeStyleEditor/index.js new file mode 100644 index 000000000000..def85e561000 --- /dev/null +++ b/src/devtools/views/Components/NativeStyleEditor/index.js @@ -0,0 +1,68 @@ +// @flow + +import React, { Fragment, useContext, useMemo } from 'react'; +import Store from 'src/devtools/store'; +import { StoreContext } from 'src/devtools/views/context'; +import { useSubscription } from 'src/devtools/views/hooks'; +import { NativeStyleContext } from './context'; +import LayoutViewer from './LayoutViewer'; +import StyleEditor from './StyleEditor'; +import { TreeStateContext } from '../TreeContext'; + +type Props = {||}; + +export default function NativeStyleEditorWrapper(_: Props) { + const store = useContext(StoreContext); + + const subscription = useMemo( + () => ({ + getCurrentValue: () => store.supportsNativeStyleEditor, + subscribe: (callback: Function) => { + store.addListener('supportsNativeStyleEditor', callback); + return () => { + store.removeListener('supportsNativeStyleEditor', callback); + }; + }, + }), + [store] + ); + const supportsNativeStyleEditor = useSubscription( + subscription + ); + + if (!supportsNativeStyleEditor) { + return null; + } + + return ; +} + +function NativeStyleEditor(_: Props) { + const { getStyleAndLayout } = useContext(NativeStyleContext); + + const { inspectedElementID } = useContext(TreeStateContext); + if (inspectedElementID === null) { + return null; + } + + const maybeStyleAndLayout = getStyleAndLayout(inspectedElementID); + if (maybeStyleAndLayout === null) { + return null; + } + + const { layout, style } = maybeStyleAndLayout; + + return ( + + {layout !== null && ( + + )} + {style !== null && ( + + )} + + ); +} diff --git a/src/devtools/views/Components/NativeStyleEditor/types.js b/src/devtools/views/Components/NativeStyleEditor/types.js new file mode 100644 index 000000000000..882412af3a1c --- /dev/null +++ b/src/devtools/views/Components/NativeStyleEditor/types.js @@ -0,0 +1,13 @@ +// @flow + +import type { + Layout as LayoutBackend, + Style as StyleBackend, +} from 'src/backend/NativeStyleEditor/types'; + +export type Layout = LayoutBackend; +export type Style = StyleBackend; +export type StyleAndLayout = {| + layout: LayoutBackend | null, + style: StyleBackend | null, +|}; diff --git a/src/devtools/views/Components/SelectedElement.css b/src/devtools/views/Components/SelectedElement.css index eb0169e4da07..b6b0b6c62cfb 100644 --- a/src/devtools/views/Components/SelectedElement.css +++ b/src/devtools/views/Components/SelectedElement.css @@ -41,18 +41,6 @@ text-overflow: ellipsis; max-width: 100%; } -.Component:before, -.Owner:before { - white-space: nowrap; - content: '<'; - color: var(--color-jsx-arrow-brackets); -} -.Component:after, -.Owner:after { - white-space: nowrap; - content: '>'; - color: var(--color-jsx-arrow-brackets); -} .Component { flex: 1 1 auto; diff --git a/src/devtools/views/Components/SelectedElement.js b/src/devtools/views/Components/SelectedElement.js index 93cccda033f9..2d3eece42954 100644 --- a/src/devtools/views/Components/SelectedElement.js +++ b/src/devtools/views/Components/SelectedElement.js @@ -12,6 +12,7 @@ import HocBadges from './HocBadges'; import InspectedElementTree from './InspectedElementTree'; import { InspectedElementContext } from './InspectedElementContext'; import ViewElementSourceContext from './ViewElementSourceContext'; +import NativeStyleEditor from './NativeStyleEditor'; import Toggle from '../Toggle'; import Badge from './Badge'; import { @@ -34,7 +35,9 @@ export type Props = {||}; export default function SelectedElement(_: Props) { const { inspectedElementID } = useContext(TreeStateContext); const dispatch = useContext(TreeDispatcherContext); - const viewElementSource = useContext(ViewElementSourceContext); + const { isFileLocationRequired, viewElementSourceFunction } = useContext( + ViewElementSourceContext + ); const bridge = useContext(BridgeContext); const store = useContext(StoreContext); const { dispatch: modalDialogDispatch } = useContext(ModalDialogContext); @@ -55,7 +58,7 @@ export default function SelectedElement(_: Props) { if (element !== null && inspectedElementID !== null) { const rendererID = store.getRendererIDForElement(inspectedElementID); if (rendererID !== null) { - bridge.send('highlightElementInDOM', { + bridge.send('highlightNativeElement', { displayName: element.displayName, hideAfterTimeout: true, id: inspectedElementID, @@ -80,15 +83,19 @@ export default function SelectedElement(_: Props) { }, [bridge, inspectedElementID, store]); const viewSource = useCallback(() => { - if (viewElementSource != null && inspectedElementID !== null) { - viewElementSource(inspectedElementID); + if (viewElementSourceFunction != null && inspectedElement !== null) { + viewElementSourceFunction( + inspectedElement.id, + ((inspectedElement: any): InspectedElement) + ); } - }, [inspectedElementID, viewElementSource]); + }, [inspectedElement, viewElementSourceFunction]); const canViewSource = inspectedElement && inspectedElement.canViewSource && - viewElementSource !== null; + viewElementSourceFunction !== null && + (!isFileLocationRequired || inspectedElement.source !== null); const isSuspended = element !== null && @@ -344,6 +351,8 @@ function InspectedElementView({ /> )} + + {ownerID === null && owners !== null && owners.length > 0 && (
rendered by
diff --git a/src/devtools/views/Components/Tree.js b/src/devtools/views/Components/Tree.js index 9663f79a74ee..85d3ef165819 100644 --- a/src/devtools/views/Components/Tree.js +++ b/src/devtools/views/Components/Tree.js @@ -1,6 +1,7 @@ // @flow import React, { + Fragment, Suspense, useState, useCallback, @@ -77,14 +78,14 @@ export default function Tree(props: Props) { // Picking an element in the inspector should put focus into the tree. // This ensures that keyboard navigation works right after picking a node. useEffect(() => { - function handleStopInspectingDOM(didSelectNode) { + function handleStopInspectingNative(didSelectNode) { if (didSelectNode && focusTargetRef.current !== null) { focusTargetRef.current.focus(); } } - bridge.addListener('stopInspectingDOM', handleStopInspectingDOM); + bridge.addListener('stopInspectingNative', handleStopInspectingNative); return () => - bridge.removeListener('stopInspectingDOM', handleStopInspectingDOM); + bridge.removeListener('stopInspectingNative', handleStopInspectingNative); }, [bridge]); // This ref is passed down the context to elements. @@ -187,12 +188,12 @@ export default function Tree(props: Props) { [dispatch, selectedElementID] ); - const highlightElementInDOM = useCallback( + const highlightNativeElement = useCallback( (id: number) => { const element = store.getElementByID(id); const rendererID = store.getRendererIDForElement(id); if (element !== null) { - bridge.send('highlightElementInDOM', { + bridge.send('highlightNativeElement', { displayName: element.displayName, hideAfterTimeout: false, id, @@ -220,15 +221,15 @@ export default function Tree(props: Props) { } if (isNavigatingWithKeyboard || didSelectNewSearchResult) { if (selectedElementID !== null) { - highlightElementInDOM(selectedElementID); + highlightNativeElement(selectedElementID); } else { - bridge.send('clearHighlightedElementInDOM'); + bridge.send('clearNativeElementHighlight'); } } }, [ bridge, isNavigatingWithKeyboard, - highlightElementInDOM, + highlightNativeElement, searchIndex, searchResults, selectedElementID, @@ -240,10 +241,10 @@ export default function Tree(props: Props) { // Ignore hover while we're navigating with keyboard. // This avoids flicker from the hovered nodes under the mouse. if (!isNavigatingWithKeyboard) { - highlightElementInDOM(id); + highlightNativeElement(id); } }, - [isNavigatingWithKeyboard, highlightElementInDOM] + [isNavigatingWithKeyboard, highlightNativeElement] ); const handleMouseMove = useCallback(() => { @@ -253,7 +254,7 @@ export default function Tree(props: Props) { }, []); const handleMouseLeave = useCallback(() => { - bridge.send('clearHighlightedElementInDOM'); + bridge.send('clearNativeElementHighlight'); }, [bridge]); // Let react-window know to re-render any time the underlying tree data changes. @@ -284,8 +285,12 @@ export default function Tree(props: Props) {
- -
+ {store.supportsNativeInspection && ( + + +
+ + )} }> {ownerID !== null ? : } diff --git a/src/devtools/views/Components/ViewElementSourceContext.js b/src/devtools/views/Components/ViewElementSourceContext.js index 0ce3191cff0a..0e76f7c56eb3 100644 --- a/src/devtools/views/Components/ViewElementSourceContext.js +++ b/src/devtools/views/Components/ViewElementSourceContext.js @@ -2,7 +2,14 @@ import { createContext } from 'react'; -const ViewElementSourceContext = createContext(null); +import type { ViewElementSource } from 'src/devtools/views/DevTools'; + +export type Context = {| + isFileLocationRequired: boolean, + viewElementSourceFunction: ViewElementSource | null, +|}; + +const ViewElementSourceContext = createContext(((null: any): Context)); ViewElementSourceContext.displayName = 'ViewElementSourceContext'; export default ViewElementSourceContext; diff --git a/src/devtools/views/DevTools.css b/src/devtools/views/DevTools.css index 34a530b16b90..efc59b78dba6 100644 --- a/src/devtools/views/DevTools.css +++ b/src/devtools/views/DevTools.css @@ -16,6 +16,10 @@ border-top: 1px solid var(--color-border); font-family: var(--font-family-sans); font-size: var(--font-size-sans-large); + user-select: none; + + /* Electron drag area */ + -webkit-app-region: drag; } .Spacer { @@ -27,12 +31,24 @@ overflow: auto; } +.DevToolsVersion { + font-size: var(--font-size-sans-normal); + margin-right: 0.5rem; +} + .DevToolsVersion:before { + font-size: var(--font-size-sans-large); content: 'DevTools '; } -@media screen and (max-width: 350px) { +@media screen and (max-width: 400px) { .DevToolsVersion:before { content: ''; } } + +@media screen and (max-width: 300px) { + .DevToolsVersion { + display: none; + } +} diff --git a/src/devtools/views/DevTools.js b/src/devtools/views/DevTools.js index 2700c66e9c37..7a04ee46bc83 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, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import Bridge from 'src/bridge'; import Store from '../store'; import { BridgeContext, StoreContext } from './context'; @@ -23,18 +23,23 @@ import styles from './DevTools.css'; import './root.css'; -export type BrowserName = 'Chrome' | 'Firefox'; +import type { InspectedElement } from 'src/devtools/views/Components/types'; + export type BrowserTheme = 'dark' | 'light'; export type TabID = 'components' | 'profiler' | 'settings'; +export type ViewElementSource = ( + id: number, + inspectedElement: InspectedElement +) => void; export type Props = {| bridge: Bridge, - browserName: BrowserName, - browserTheme: BrowserTheme, + browserTheme?: BrowserTheme, defaultTab?: TabID, showTabBar?: boolean, store: Store, - viewElementSource?: ?Function, + viewElementSourceFunction?: ?ViewElementSource, + viewElementSourceRequiresFileLocation?: boolean, // This property is used only by the web extension target. // The built-in tab UI is hidden in that case, in favor of the browser's own panel tabs. @@ -67,7 +72,6 @@ const tabs = [componentsTab, profilerTab]; export default function DevTools({ bridge, - browserName, browserTheme = 'light', defaultTab = 'components', componentsPortalContainer, @@ -76,13 +80,22 @@ export default function DevTools({ settingsPortalContainer, showTabBar = false, store, - viewElementSource = null, + viewElementSourceFunction, + viewElementSourceRequiresFileLocation = false, }: Props) { const [tab, setTab] = useState(defaultTab); if (overrideTab != null && overrideTab !== tab) { setTab(overrideTab); } + const viewElementSource = useMemo( + () => ({ + isFileLocationRequired: viewElementSourceRequiresFileLocation, + viewElementSourceFunction: viewElementSourceFunction || null, + }), + [viewElementSourceFunction, viewElementSourceRequiresFileLocation] + ); + return ( diff --git a/src/devtools/views/Profiler/CommitTreeBuilder.js b/src/devtools/views/Profiler/CommitTreeBuilder.js index 35ad483aeeb5..d805f92b1cc7 100644 --- a/src/devtools/views/Profiler/CommitTreeBuilder.js +++ b/src/devtools/views/Profiler/CommitTreeBuilder.js @@ -139,7 +139,7 @@ function recursivelyInitializeTree( function updateTree( commitTree: CommitTree, - operations: Uint32Array + operations: Array ): CommitTree { // Clone the original tree so edits don't affect it. const nodes = new Map(commitTree.nodes); diff --git a/src/devtools/views/Profiler/Profiler.css b/src/devtools/views/Profiler/Profiler.css index f17546eba938..a7eeb78a181a 100644 --- a/src/devtools/views/Profiler/Profiler.css +++ b/src/devtools/views/Profiler/Profiler.css @@ -42,6 +42,11 @@ flex-direction: column; align-items: center; justify-content: center; + padding: 0 1rem; +} + +.Paragraph { + text-align: center; } .Row { diff --git a/src/devtools/views/Profiler/Profiler.js b/src/devtools/views/Profiler/Profiler.js index efa729d2582d..a134f1d016b4 100644 --- a/src/devtools/views/Profiler/Profiler.js +++ b/src/devtools/views/Profiler/Profiler.js @@ -150,24 +150,22 @@ const NoProfilingData = () => ( const ProfilingNotSupported = () => (
Profiling not supported.
-
-

- Profiling support requires either a development or production-profiling - build of React v16.5+. -

-

- Learn more at{' '} - - fb.me/react-profiling - - . -

-
+

+ Profiling support requires either a development or production-profiling + build of React v16.5+. +

+

+ Learn more at{' '} + + fb.me/react-profiling + + . +

); diff --git a/src/devtools/views/Profiler/SidebarSelectedFiberInfo.css b/src/devtools/views/Profiler/SidebarSelectedFiberInfo.css index 77a29574d074..2ea9526d9653 100644 --- a/src/devtools/views/Profiler/SidebarSelectedFiberInfo.css +++ b/src/devtools/views/Profiler/SidebarSelectedFiberInfo.css @@ -20,20 +20,12 @@ .Component { flex: 1; color: var(--color-component-name); + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-normal); white-space: nowrap; overflow-x: hidden; text-overflow: ellipsis; } -.Component:before { - white-space: nowrap; - content: '<'; - color: var(--color-jsx-arrow-brackets); -} -.Component:after { - white-space: nowrap; - content: '>'; - color: var(--color-jsx-arrow-brackets); -} .Label { font-weight: bold; diff --git a/src/devtools/views/Profiler/types.js b/src/devtools/views/Profiler/types.js index c99a79fed8b9..b6c2fcc2fdcc 100644 --- a/src/devtools/views/Profiler/types.js +++ b/src/devtools/views/Profiler/types.js @@ -87,7 +87,7 @@ export type ProfilingDataForRootFrontend = {| // 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, + operations: Array>, // Identifies the root this profiler data corresponds to. rootID: number, diff --git a/src/devtools/views/Profiler/utils.js b/src/devtools/views/Profiler/utils.js index bd4d1e9f3195..e0f1dcdd834d 100644 --- a/src/devtools/views/Profiler/utils.js +++ b/src/devtools/views/Profiler/utils.js @@ -28,7 +28,7 @@ const commitGradient = [ // This format can then be quickly exported (and re-imported). export function prepareProfilingDataFrontendFromBackendAndStore( dataBackends: Array, - operationsByRootID: Map>, + operationsByRootID: Map>>, screenshotsByRootID: Map>, snapshotsByRootID: Map> ): ProfilingDataFrontend { @@ -137,7 +137,7 @@ export function prepareProfilingDataFrontendFromExport( initialTreeBaseDurations: new Map(initialTreeBaseDurations), interactionCommits: new Map(interactionCommits), interactions: new Map(interactions), - operations: operations.map(array => Uint32Array.from(array)), // Convert Array back to Uint32Array + operations, rootID, snapshots: new Map(snapshots), }); @@ -194,7 +194,7 @@ export function prepareProfilingDataExport( ), interactionCommits: Array.from(interactionCommits.entries()), interactions: Array.from(interactions.entries()), - operations: operations.map(array => Array.from(array)), // Convert Uint32Array to Array for serialization + operations, rootID, snapshots: Array.from(snapshots.entries()), }); diff --git a/src/devtools/views/Settings/SettingsContext.js b/src/devtools/views/Settings/SettingsContext.js index d18ffe0dbffa..70ad39f6134a 100644 --- a/src/devtools/views/Settings/SettingsContext.js +++ b/src/devtools/views/Settings/SettingsContext.js @@ -213,6 +213,7 @@ function updateThemeVariables( updateStyleHelper(theme, 'color-background', documentElements); updateStyleHelper(theme, 'color-background-hover', documentElements); updateStyleHelper(theme, 'color-background-inactive', documentElements); + updateStyleHelper(theme, 'color-background-invalid', documentElements); updateStyleHelper(theme, 'color-background-selected', documentElements); updateStyleHelper(theme, 'color-border', documentElements); updateStyleHelper(theme, 'color-button-background', documentElements); @@ -275,12 +276,6 @@ function updateThemeVariables( updateStyleHelper(theme, 'color-dimmer', documentElements); updateStyleHelper(theme, 'color-dimmest', documentElements); updateStyleHelper(theme, 'color-expand-collapse-toggle', documentElements); - updateStyleHelper(theme, 'color-jsx-arrow-brackets', documentElements); - updateStyleHelper( - theme, - 'color-jsx-arrow-brackets-inverted', - documentElements - ); updateStyleHelper(theme, 'color-modal-background', documentElements); updateStyleHelper(theme, 'color-record-active', documentElements); updateStyleHelper(theme, 'color-record-hover', documentElements); @@ -301,6 +296,7 @@ function updateThemeVariables( ); updateStyleHelper(theme, 'color-tab-selected-border', documentElements); updateStyleHelper(theme, 'color-text', documentElements); + updateStyleHelper(theme, 'color-text-invalid', documentElements); updateStyleHelper(theme, 'color-text-selected', documentElements); updateStyleHelper(theme, 'color-toggle-background-invalid', documentElements); updateStyleHelper(theme, 'color-toggle-background-on', documentElements); diff --git a/src/devtools/views/TabBar.css b/src/devtools/views/TabBar.css index 1218421eec8e..ea6fefbbc36e 100644 --- a/src/devtools/views/TabBar.css +++ b/src/devtools/views/TabBar.css @@ -9,6 +9,9 @@ border-bottom: 3px solid transparent; user-select: none; color: var(--color-text); + + /* Electron drag area */ + -webkit-app-region: no-drag; } .Tab:hover, .TabCurrent:hover { diff --git a/src/devtools/views/root.css b/src/devtools/views/root.css index 30e70443ecf1..b822462a3cc2 100644 --- a/src/devtools/views/root.css +++ b/src/devtools/views/root.css @@ -12,6 +12,7 @@ --light-color-background: #ffffff; --light-color-background-hover: rgba(0, 136, 250, 0.1); --light-color-background-inactive: #e5e5e5; + --light-color-background-invalid: #fff0f0; --light-color-background-selected: #0088fa; --light-color-button-background: #ffffff; --light-color-button-background-focus: #ededed; @@ -46,8 +47,6 @@ --light-color-dimmer: #cfd1d5; --light-color-dimmest: #eff0f1; --light-color-expand-collapse-toggle: #777d88; - --light-color-jsx-arrow-brackets: #333333; - --light-color-jsx-arrow-brackets-inverted: rgba(255, 255, 255, 0.7); --light-color-modal-background: rgba(255, 255, 255, 0.75); --light-color-record-active: #fc3a4b; --light-color-record-hover: #3578e5; @@ -60,6 +59,7 @@ --light-color-selected-tree-highlight-inactive: rgba(0, 0, 0, 0.05); --light-color-tab-selected-border: #0088fa; --light-color-text: #000000; + --light-color-text-invalid: #ff0000; --light-color-text-selected: #ffffff; --light-color-toggle-background-invalid: #fc3a4b; --light-color-toggle-background-on: #0088fa; @@ -77,6 +77,7 @@ --dark-color-background: #282c34; --dark-color-background-hover: rgba(255, 255, 255, 0.1); --dark-color-background-inactive: #3d424a; + --dark-color-background-invalid: #5c0000; --dark-color-background-selected: #178fb9; --dark-color-button-background: #282c34; --dark-color-button-background-focus: #3d424a; @@ -111,8 +112,6 @@ --dark-color-dimmer: #777d88; --dark-color-dimmest: #4f5766; --dark-color-expand-collapse-toggle: #8f949d; - --dark-color-jsx-arrow-brackets: #777d88; - --dark-color-jsx-arrow-brackets-inverted: rgba(255, 255, 255, 0.7); --dark-color-modal-background: rgba(0, 0, 0, 0.75); --dark-color-record-active: #fc3a4b; --dark-color-record-hover: #a2e9fc; @@ -125,6 +124,7 @@ --dark-color-selected-tree-highlight-inactive: rgba(255, 255, 255, 0.05); --dark-color-tab-selected-border: #178fb9; --dark-color-text: #ffffff; + --dark-color-text-invalid: #ff8080; --dark-color-text-selected: #ffffff; --dark-color-toggle-background-invalid: #fc3a4b; --dark-color-toggle-background-on: #178fb9; diff --git a/src/hydration.js b/src/hydration.js index 9aa36f61b121..ac94f73d1c93 100644 --- a/src/hydration.js +++ b/src/hydration.js @@ -43,45 +43,79 @@ type Dehydrated = {| // but may decrease the responsiveness of expanding objects/arrays to inspect further. const LEVEL_THRESHOLD = 2; +type PropType = + | 'array' + | 'array_buffer' + | 'boolean' + | 'data_view' + | 'date' + | 'function' + | 'html_element' + | 'infinity' + | 'iterator' + | 'nan' + | 'null' + | 'number' + | 'object' + | 'react_element' + | 'string' + | 'symbol' + | 'typed_array' + | 'undefined' + | 'unknown'; + /** * Get a enhanced/artificial type string based on the object instance */ -function getPropType(data: Object): string | null { - if (!data) { - return null; +function getDataType(data: Object): PropType { + if (data === null) { + return 'null'; + } else if (data === undefined) { + return 'undefined'; } if (isElement(data)) { return 'react_element'; } - if (data instanceof HTMLElement) { + if (typeof HTMLElement !== 'undefined' && data instanceof HTMLElement) { return 'html_element'; } const type = typeof data; - if (type === 'object') { - if (Array.isArray(data)) { - return 'array'; - } - if (ArrayBuffer.isView(data)) { - if (data instanceof DataView) { - return 'data_view'; + switch (type) { + case 'boolean': + return 'boolean'; + case 'function': + return 'function'; + case 'number': + if (Number.isNaN(data)) { + return 'nan'; + } else if (!Number.isFinite(data)) { + return 'infinity'; + } else { + return 'number'; } - return 'typed_array'; - } - if (data instanceof ArrayBuffer) { - return 'array_buffer'; - } - if (typeof data[Symbol.iterator] === 'function') { - return 'iterator'; - } - if (Object.prototype.toString.call(data) === '[object Date]') { - return 'date'; - } + case 'object': + if (Array.isArray(data)) { + return 'array'; + } else if (ArrayBuffer.isView(data)) { + return data instanceof DataView ? 'data_view' : 'typed_array'; + } else if (data instanceof ArrayBuffer) { + return 'array_buffer'; + } else if (typeof data[Symbol.iterator] === 'function') { + return 'iterator'; + } else if (Object.prototype.toString.call(data) === '[object Date]') { + return 'date'; + } + return 'object'; + case 'string': + return 'string'; + case 'symbol': + return 'symbol'; + default: + return 'unknown'; } - - return type; } /** @@ -143,7 +177,7 @@ export function dehydrate( isPathWhitelisted: (path: Array) => boolean, level?: number = 0 ): string | Dehydrated | { [key: string]: string | Dehydrated } { - const type = getPropType(data); + const type = getDataType(data); switch (type) { case 'html_element': @@ -151,7 +185,7 @@ export function dehydrate( return { inspectable: false, name: data.tagName, - type: 'html_element', + type, }; case 'function': @@ -159,20 +193,18 @@ export function dehydrate( return { inspectable: false, name: data.name, - type: 'function', + type, }; case 'string': return data.length <= 500 ? data : data.slice(0, 500) + '...'; - // We have to do this assignment b/c Flow doesn't think "symbol" is - // something typeof would return. Error 'unexpected predicate "symbol"' case 'symbol': cleaned.push(path); return { inspectable: false, name: data.toString(), - type: 'symbol', + type, }; // React Elements aren't very inspector-friendly, @@ -182,7 +214,7 @@ export function dehydrate( return { inspectable: false, name: getDisplayNameForReactElement(data), - type: 'react_element', + type, }; // ArrayBuffers error if you try to inspect them. @@ -220,7 +252,7 @@ export function dehydrate( return { inspectable: false, name: data.toString(), - type: 'date', + type, }; case 'object': @@ -241,6 +273,16 @@ export function dehydrate( return object; } + case 'infinity': + case 'nan': + case 'undefined': + // Some values are lossy when sent through a WebSocket. + // We dehydrate+rehydrate them to preserve their type. + cleaned.push(path); + return { + type, + }; + default: return data; } @@ -271,22 +313,30 @@ export function hydrate( const length = path.length; const last = path[length - 1]; const parent = getInObject(object, path.slice(0, length - 1)); - if (!parent || !parent[last]) { + if (!parent || !parent.hasOwnProperty(last)) { return; } const value = parent[last]; - // Replace the string keys with Symbols so they're non-enumerable. - const replaced: { [key: Symbol]: boolean | string } = {}; - replaced[meta.inspectable] = !!value.inspectable; - replaced[meta.inspected] = false; - replaced[meta.name] = value.name; - replaced[meta.size] = value.size; - replaced[meta.readonly] = !!value.readonly; - replaced[meta.type] = value.type; - - parent[last] = replaced; + if (value.type === 'infinity') { + parent[last] = Infinity; + } else if (value.type === 'nan') { + parent[last] = NaN; + } else if (value.type === 'undefined') { + parent[last] = undefined; + } else { + // Replace the string keys with Symbols so they're non-enumerable. + const replaced: { [key: Symbol]: boolean | string } = {}; + replaced[meta.inspectable] = !!value.inspectable; + replaced[meta.inspected] = false; + replaced[meta.name] = value.name; + replaced[meta.size] = value.size; + replaced[meta.readonly] = !!value.readonly; + replaced[meta.type] = value.type; + + parent[last] = replaced; + } }); return object; } diff --git a/src/utils.js b/src/utils.js index fe65aade77e9..3f73a7967173 100644 --- a/src/utils.js +++ b/src/utils.js @@ -58,27 +58,25 @@ export function getUID(): number { return ++uidCounter; } -export function utfDecodeString(array: Uint32Array): string { +export function utfDecodeString(array: Array): string { return String.fromCodePoint(...array); } -export function utfEncodeString(string: string): Uint32Array { +export function utfEncodeString(string: string): Array { let cached = encodedStringCache.get(string); if (cached !== undefined) { return cached; } - // $FlowFixMe Flow's Uint32Array.from's type definition is wrong; first argument of mapFn will be string - const encoded = Uint32Array.from(string, toCodePoint); + const encoded = new Array(string.length); + for (let i = 0; i < string.length; i++) { + encoded[i] = string.codePointAt(i); + } encodedStringCache.set(string, encoded); return encoded; } -function toCodePoint(string: string) { - return string.codePointAt(0); -} - -export function printOperationsArray(operations: Uint32Array) { +export function printOperationsArray(operations: Array) { // The first two values are always rendererID and rootID const rendererID = operations[0]; const rootID = operations[1]; diff --git a/yarn.lock b/yarn.lock index cb35f65ede94..80d36901ff0a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1165,6 +1165,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-11.13.4.tgz#f83ec3c3e05b174b7241fadeb6688267fe5b22ca" integrity sha512-+rabAZZ3Yn7tF/XPGHupKIL5EcAbrLxnTr/hgQICxbeuAfWtT0UZSfULE+ndusckBItcv4o6ZeOJplQikVcLvQ== +"@types/node@^10.12.18": + version "10.14.9" + resolved "https://registry.yarnpkg.com/@types/node/-/node-10.14.9.tgz#2e8d678039d27943ce53a1913386133227fd9066" + integrity sha512-NelG/dSahlXYtSoVPErrp06tYFrvzj8XLWmKA+X8x0W//4MqbUyZu++giUG/v0bjAT6/Qxa8IjodrfdACyb0Fg== + "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" @@ -1885,7 +1890,7 @@ async-each@^1.0.1: resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" integrity sha1-GdOGodntxufByF04iu28xW0zYC0= -async-limiter@~1.0.0: +async-limiter@^1.0.0, async-limiter@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8" @@ -3297,7 +3302,7 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= -concat-stream@^1.4.10, concat-stream@^1.5.0, concat-stream@^1.5.2, concat-stream@^1.6.0: +concat-stream@1.6.2, concat-stream@^1.4.10, concat-stream@^1.5.0, concat-stream@^1.5.2, concat-stream@^1.6.0: version "1.6.2" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== @@ -3669,6 +3674,14 @@ create-react-class@^15.6.0: loose-envify "^1.3.1" object-assign "^4.1.1" +cross-env@^3.1.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-3.2.4.tgz#9e0585f277864ed421ce756f81a980ff0d698aba" + integrity sha1-ngWF8neGTtQhznVvgamA/w1piro= + dependencies: + cross-spawn "^5.1.0" + is-windows "^1.0.0" + cross-env@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-5.2.0.tgz#6ecd4c015d5773e614039ee529076669b9d126f2" @@ -3901,7 +3914,7 @@ debounce@1.1.0: resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.1.0.tgz#6a1a4ee2a9dc4b7c24bb012558dbcdb05b37f408" integrity sha512-ZQVKfRVlwRfD150ndzEK8M90ABT+Y/JQKs4Y7U4MXdpuoUkkrr4DwKbVux3YjylA5bUMUj0Nc3pMxPJX6N2QQQ== -debug@2, debug@2.6.9, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9, debug@~2.6.3: +debug@2, debug@2.6.9, debug@^2.1.2, debug@^2.1.3, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9, debug@~2.6.3: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== @@ -3914,19 +3927,19 @@ debug@^2.1.1: dependencies: ms "0.7.2" -debug@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" - dependencies: - ms "2.0.0" - -debug@^3.2.5, debug@^3.2.6: +debug@^3.0.0, debug@^3.2.5, debug@^3.2.6: version "3.2.6" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== dependencies: ms "^2.1.1" +debug@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" + dependencies: + ms "2.0.0" + debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" @@ -4304,11 +4317,35 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= +electron-download@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/electron-download/-/electron-download-4.1.1.tgz#02e69556705cc456e520f9e035556ed5a015ebe8" + integrity sha512-FjEWG9Jb/ppK/2zToP+U5dds114fM1ZOJqMAR4aXXL5CvyPE9fiqBK/9YcwC9poIFQTEJk/EM/zyRwziziRZrg== + dependencies: + debug "^3.0.0" + env-paths "^1.0.0" + fs-extra "^4.0.1" + minimist "^1.2.0" + nugget "^2.0.1" + path-exists "^3.0.0" + rc "^1.2.1" + semver "^5.4.1" + sumchecker "^2.0.2" + electron-to-chromium@^1.3.113: version "1.3.113" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.113.tgz#b1ccf619df7295aea17bc6951dc689632629e4a9" integrity sha512-De+lPAxEcpxvqPTyZAXELNpRZXABRxf+uL/rSykstQhzj/B0l1150G/ExIIxKc16lI89Hgz81J0BHAcbTqK49g== +electron@^5.0.0: + version "5.0.4" + resolved "https://registry.yarnpkg.com/electron/-/electron-5.0.4.tgz#2e0d09055363e983f4a73317cde4821c39617b02" + integrity sha512-7QaKorvANvP+azMT7wElx33oLlqw8QxmLs7/outfH7LC5amErk4EUtWDesQ6Zgr+s5pYFbykl8ZtJ4ZGXER05g== + dependencies: + "@types/node" "^10.12.18" + electron-download "^4.1.0" + extract-zip "^1.0.3" + elegant-spinner@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e" @@ -4382,6 +4419,11 @@ entities@^1.1.1, entities@~1.1.1: resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== +env-paths@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-1.0.0.tgz#4168133b42bb05c38a35b1ae4397c8298ab369e0" + integrity sha1-QWgTO0K7BcOKNbGuQ5fIKYqzaeA= + errno@^0.1.3, errno@~0.1.7: version "0.1.7" resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618" @@ -5171,6 +5213,16 @@ extglob@^2.0.4: snapdragon "^0.8.1" to-regex "^3.0.1" +extract-zip@^1.0.3: + version "1.6.7" + resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.6.7.tgz#a840b4b8af6403264c8db57f4f1a74333ef81fe9" + integrity sha1-qEC0uK9kAyZMjbV/Txp0Mz74H+k= + dependencies: + concat-stream "1.6.2" + debug "2.6.9" + mkdirp "0.5.1" + yauzl "2.4.1" + extsprintf@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" @@ -5289,6 +5341,13 @@ fbjs@^0.8.9: setimmediate "^1.0.5" ua-parser-js "^0.7.18" +fd-slicer@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.0.1.tgz#8b5bcbd9ec327c5041bf9ab023fd6750f1177e65" + integrity sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU= + dependencies: + pend "~1.2.0" + fd-slicer@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" @@ -6507,7 +6566,7 @@ ip-regex@^2.1.0: resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= -ip@^1.1.0, ip@^1.1.5: +ip@^1.1.0, ip@^1.1.4, ip@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= @@ -8207,7 +8266,7 @@ memory-fs@^0.4.0, memory-fs@^0.4.1, memory-fs@~0.4.1: errno "^0.1.3" readable-stream "^2.0.1" -meow@^3.3.0: +meow@^3.1.0, meow@^3.3.0: version "3.7.0" resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb" integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs= @@ -8851,6 +8910,19 @@ nth-check@~1.0.1: dependencies: boolbase "~1.0.0" +nugget@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/nugget/-/nugget-2.0.1.tgz#201095a487e1ad36081b3432fa3cada4f8d071b0" + integrity sha1-IBCVpIfhrTYIGzQy+jytpPjQcbA= + dependencies: + debug "^2.1.3" + minimist "^1.1.0" + pretty-bytes "^1.0.2" + progress-stream "^1.1.0" + request "^2.45.0" + single-line-log "^1.1.2" + throttleit "0.0.2" + nullthrows@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1" @@ -8901,6 +8973,11 @@ object-keys@^1.0.12: resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.0.12.tgz#09c53855377575310cca62f55bb334abff7b3ed2" integrity sha512-FTMyFUm2wBcGHnH2eXmz7tC6IwlqQZ6mVZ+6dm6vZ4IQIHjs6FdNsQBuKGPuUUUY6NfJw2PshC08Tn6LzLDOag== +object-keys@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-0.4.0.tgz#28a6aae7428dd2c3a92f3d95f21335dd204e0336" + integrity sha1-KKaq50KN0sOpLz2V8hM13SBOAzY= + object-visit@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" @@ -9545,6 +9622,14 @@ prettier@^1.16.4: resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.16.4.tgz#73e37e73e018ad2db9c76742e2647e21790c9717" integrity sha512-ZzWuos7TI5CKUeQAtFd6Zhm2s6EpAD/ZLApIhsF9pRvRtM1RFo61dM/4MSRUA0SuLugA/zgrZD8m0BaY46Og7g== +pretty-bytes@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-1.0.4.tgz#0a22e8210609ad35542f8c8d5d2159aff0751c84" + integrity sha1-CiLoIQYJrTVUL4yNXSFZr/B1HIQ= + dependencies: + get-stdin "^4.0.1" + meow "^3.1.0" + pretty-format@^23.6.0: version "23.6.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-23.6.0.tgz#5eaac8eeb6b33b987b7fe6097ea6a8a146ab5760" @@ -9590,6 +9675,14 @@ process@^0.11.0, process@^0.11.10, process@~0.11.0: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= +progress-stream@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/progress-stream/-/progress-stream-1.2.0.tgz#2cd3cfea33ba3a89c9c121ec3347abe9ab125f77" + integrity sha1-LNPP6jO6OonJwSHsM0er6asSX3c= + dependencies: + speedometer "~0.1.2" + through2 "~0.2.3" + progress@^1.1.8: version "1.1.8" resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be" @@ -9808,7 +9901,7 @@ raw-body@2.3.3: iconv-lite "0.4.23" unpipe "1.0.0" -rc@^1.0.1, rc@^1.1.6, rc@^1.2.7: +rc@^1.0.1, rc@^1.1.6, rc@^1.2.1, rc@^1.2.7: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== @@ -10280,6 +10373,32 @@ request@2.87.0: tunnel-agent "^0.6.0" uuid "^3.1.0" +request@^2.45.0, request@~2.88.0: + version "2.88.0" + resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" + integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg== + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.0" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.4.3" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + request@^2.83.0: version "2.83.0" resolved "https://registry.yarnpkg.com/request/-/request-2.83.0.tgz#ca0b65da02ed62935887808e6f510381034e3356" @@ -10307,32 +10426,6 @@ request@^2.83.0: tunnel-agent "^0.6.0" uuid "^3.1.0" -request@~2.88.0: - version "2.88.0" - resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" - integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg== - dependencies: - aws-sign2 "~0.7.0" - aws4 "^1.8.0" - caseless "~0.12.0" - combined-stream "~1.0.6" - extend "~3.0.2" - forever-agent "~0.6.1" - form-data "~2.3.2" - har-validator "~5.1.0" - http-signature "~1.2.0" - is-typedarray "~1.0.0" - isstream "~0.1.2" - json-stringify-safe "~5.0.1" - mime-types "~2.1.19" - oauth-sign "~0.9.0" - performance-now "^2.1.0" - qs "~6.5.2" - safe-buffer "^5.1.2" - tough-cookie "~2.4.3" - tunnel-agent "^0.6.0" - uuid "^3.3.2" - require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -10766,7 +10859,7 @@ shebang-regex@^1.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= -shell-quote@1.6.1: +shell-quote@1.6.1, shell-quote@^1.6.1: version "1.6.1" resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.6.1.tgz#f4781949cce402697127430ea3b3c5476f481767" integrity sha1-9HgZSczkAmlxJ0MOo7PFR29IF2c= @@ -10819,6 +10912,13 @@ signal-exit@^3.0.0, signal-exit@^3.0.2: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= +single-line-log@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/single-line-log/-/single-line-log-1.1.2.tgz#c2f83f273a3e1a16edb0995661da0ed5ef033364" + integrity sha1-wvg/Jzo+GhbtsJlWYdoO1e8DM2Q= + dependencies: + string-width "^1.0.1" + sisteransi@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.0.tgz#77d9622ff909080f1c19e5f4a1df0c1b0a27b88c" @@ -11062,6 +11162,11 @@ spdy@^4.0.0: select-hose "^2.0.0" spdy-transport "^3.0.0" +speedometer@~0.1.2: + version "0.1.4" + resolved "https://registry.yarnpkg.com/speedometer/-/speedometer-0.1.4.tgz#9876dbd2a169d3115402d48e6ea6329c8816a50d" + integrity sha1-mHbb0qFp0xFUAtSObqYynIgWpQ0= + split-string@^3.0.1, split-string@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" @@ -11396,6 +11501,13 @@ style-loader@^0.23.1: loader-utils "^1.1.0" schema-utils "^1.0.0" +sumchecker@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/sumchecker/-/sumchecker-2.0.2.tgz#0f42c10e5d05da5d42eea3e56c3399a37d6c5b3e" + integrity sha1-D0LBDl0F2l1C7qPlbDOZo31sWz4= + dependencies: + debug "^2.2.0" + supports-color@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" @@ -11588,6 +11700,11 @@ throat@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/throat/-/throat-4.1.0.tgz#89037cbc92c56ab18926e6ba4cbb200e15672a6a" +throttleit@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-0.0.2.tgz#cfedf88e60c00dd9697b61fdd2a8343a9b680eaf" + integrity sha1-z+34jmDADdlpe2H90qg0OptoDq8= + through2@^2.0.0, through2@^2.0.2: version "2.0.5" resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" @@ -11596,6 +11713,14 @@ through2@^2.0.0, through2@^2.0.2: readable-stream "~2.3.6" xtend "~4.0.1" +through2@~0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/through2/-/through2-0.2.3.tgz#eb3284da4ea311b6cc8ace3653748a52abf25a3f" + integrity sha1-6zKE2k6jEbbMis42U3SKUqvyWj8= + dependencies: + readable-stream "~1.1.9" + xtend "~2.1.1" + through@2, "through@>=2.2.7 <3", through@^2.3.4, through@^2.3.6: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" @@ -11919,6 +12044,22 @@ update-notifier@2.3.0: semver-diff "^2.0.0" xdg-basedir "^3.0.0" +update-notifier@^2.1.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.5.0.tgz#d0744593e13f161e406acb1d9408b72cad08aff6" + integrity sha512-gwMdhgJHGuj/+wHJJs9e6PcCszpxR1b236igrOkUofGhqJuG+amlIKwApH1IW1WWl7ovZxsX49lMBWLxSdm5Dw== + dependencies: + boxen "^1.2.1" + chalk "^2.0.1" + configstore "^3.0.0" + import-lazy "^2.1.0" + is-ci "^1.0.10" + is-installed-globally "^0.1.0" + is-npm "^1.0.0" + latest-version "^3.0.0" + semver-diff "^2.0.0" + xdg-basedir "^3.0.0" + uri-js@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" @@ -12480,6 +12621,13 @@ ws@^4.0.0: safe-buffer "~5.1.0" ultron "~1.1.0" +ws@^7: + version "7.0.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.0.1.tgz#1a04e86cc3a57c03783f4910fdb090cf31b8e165" + integrity sha512-ILHfMbuqLJvnSgYXLgy4kMntroJpe8hT41dOVWM8bxRuw6TK4mgMp9VJUNsZTEc5Bh+Mbs0DJT4M0N+wBG9l9A== + dependencies: + async-limiter "^1.0.0" + xdg-basedir@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4" @@ -12517,6 +12665,13 @@ xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1: resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" integrity sha1-pcbVMr5lbiPbgg77lDofBJmNY68= +xtend@~2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-2.1.2.tgz#6efecc2a4dad8e6962c4901b337ce7ba87b5d28b" + integrity sha1-bv7MKk2tjmlixJAbM3znuoe10os= + dependencies: + object-keys "~0.4.0" + y18n@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" @@ -12672,6 +12827,13 @@ yauzl@2.10.0: buffer-crc32 "~0.2.3" fd-slicer "~1.1.0" +yauzl@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.4.1.tgz#9528f442dab1b2284e58b4379bb194e22e0c4005" + integrity sha1-lSj0QtqxsihOWLQ3m7GU4i4MQAU= + dependencies: + fd-slicer "~1.0.1" + zip-dir@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/zip-dir/-/zip-dir-1.0.2.tgz#253f907aead62a21acd8721d8b88032b2411c051"