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 000000000000..b9327946441f Binary files /dev/null and b/packages/react-devtools/icons/icon128.png differ diff --git a/packages/react-devtools/index.js b/packages/react-devtools/index.js new file mode 100644 index 000000000000..9a48b9c2b69a --- /dev/null +++ b/packages/react-devtools/index.js @@ -0,0 +1,7 @@ +// @flow + +const { connectToDevTools } = require('react-devtools-core/backend'); + +// Connect immediately with default options. +// If you need more control, use `react-devtools-core` directly instead of `react-devtools`. +connectToDevTools(); diff --git a/packages/react-devtools/package.json b/packages/react-devtools/package.json new file mode 100644 index 000000000000..e16415d677a5 --- /dev/null +++ b/packages/react-devtools/package.json @@ -0,0 +1,31 @@ +{ + "name": "react-devtools", + "version": "4.0.0", + "description": "Use react-devtools outside of the browser", + "license": "MIT", + "repository": { + "url": "https://github.com/bvaughn/react-devtools-experimental.git", + "type": "git" + }, + "bin": { + "react-devtools": "./bin.js" + }, + "files": [ + "bin.js", + "app.html", + "app.js", + "index.js", + "icons" + ], + "scripts": { + "start": "node bin.js" + }, + "dependencies": { + "cross-spawn": "^5.0.1", + "electron": "^5.0.0", + "ip": "^1.1.4", + "minimist": "^1.2.0", + "react-devtools-core": "^4.0.0", + "update-notifier": "^2.1.0" + } +} diff --git a/shells/browser/shared/src/backend.js b/shells/browser/shared/src/backend.js index 97f9e55d6efc..c8cee150d8cb 100644 --- a/shells/browser/shared/src/backend.js +++ b/shells/browser/shared/src/backend.js @@ -23,6 +23,8 @@ function setup(hook) { const Agent = require('src/backend/agent').default; const Bridge = require('src/bridge').default; const { initBackend } = require('src/backend'); + const setupNativeStyleEditor = require('src/backend/NativeStyleEditor/setupNativeStyleEditor') + .default; const bridge = new Bridge({ listen(fn) { @@ -62,4 +64,14 @@ function setup(hook) { }); initBackend(hook, agent, window); + + // Setup React Native style editor if a renderer like react-native-web has injected it. + if (!!hook.resolveRNStyle) { + setupNativeStyleEditor( + bridge, + agent, + hook.resolveRNStyle, + hook.nativeStyleEditorValidAttributes + ); + } } diff --git a/shells/browser/shared/src/main.js b/shells/browser/shared/src/main.js index 320e20678f78..3d80e951ec0b 100644 --- a/shells/browser/shared/src/main.js +++ b/shells/browser/shared/src/main.js @@ -5,11 +5,7 @@ import { unstable_createRoot as createRoot, flushSync } from 'react-dom'; import Bridge from 'src/bridge'; import Store from 'src/devtools/store'; import inject from './inject'; -import { - createViewElementSource, - getBrowserName, - getBrowserTheme, -} from './utils'; +import { createViewElementSource, getBrowserTheme } from './utils'; import { getSavedComponentFilters } from 'src/utils'; import { localStorageGetItem, @@ -121,8 +117,6 @@ function createPanelIfReactLoaded() { localStorageRemoveItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY); } - const browserName = getBrowserName(); - store = new Store(bridge, { isProfiling, supportsCaptureScreenshots: true, @@ -135,7 +129,10 @@ function createPanelIfReactLoaded() { // Otherwise the Store may miss important initial tree op codes. inject(chrome.runtime.getURL('build/backend.js')); - const viewElementSource = createViewElementSource(bridge, store); + const viewElementSourceFunction = createViewElementSource( + bridge, + store + ); root = createRoot(document.createElement('div')); @@ -145,7 +142,6 @@ function createPanelIfReactLoaded() { root.render( createElement(DevTools, { bridge, - browserName, browserTheme: getBrowserTheme(), componentsPortalContainer, overrideTab, @@ -153,7 +149,7 @@ function createPanelIfReactLoaded() { settingsPortalContainer, showTabBar: false, store, - viewElementSource, + viewElementSourceFunction, }) ); }; diff --git a/shells/browser/shared/src/utils.js b/shells/browser/shared/src/utils.js index 0446ff9daf9a..c41924459659 100644 --- a/shells/browser/shared/src/utils.js +++ b/shells/browser/shared/src/utils.js @@ -23,11 +23,15 @@ export function createViewElementSource(bridge: Bridge, store: Store) { }; } -export function getBrowserName() { +export type BrowserName = 'Chrome' | 'Firefox'; + +export function getBrowserName(): BrowserName { return IS_CHROME ? 'Chrome' : 'Firefox'; } -export function getBrowserTheme() { +export type BrowserTheme = 'dark' | 'light'; + +export function getBrowserTheme(): BrowserTheme { if (IS_CHROME) { // chrome.devtools.panels added in Chrome 18. // chrome.devtools.panels.themeName added in Chrome 54. diff --git a/shells/dev/app/InspectableElements/InspectableElements.js b/shells/dev/app/InspectableElements/InspectableElements.js index b148501ce11f..8917b3303735 100644 --- a/shells/dev/app/InspectableElements/InspectableElements.js +++ b/shells/dev/app/InspectableElements/InspectableElements.js @@ -5,6 +5,7 @@ import Contexts from './Contexts'; import CustomHooks from './CustomHooks'; import CustomObject from './CustomObject'; import NestedProps from './NestedProps'; +import SimpleValues from './SimpleValues'; // TODO Add Immutable JS example @@ -12,6 +13,7 @@ export default function InspectableElements() { return (

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"