Initial commit

This commit is contained in:
Brian Vaughn
2019-01-22 11:04:37 -08:00
commit 5e0dfdac54
43 changed files with 12581 additions and 0 deletions

7
.babelrc Normal file
View File

@@ -0,0 +1,7 @@
{
"plugins": [
["@babel/plugin-transform-flow-strip-types"],
["@babel/plugin-proposal-class-properties", { "loose": false }]
],
"presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-flow"]
}

31
.flowconfig Normal file
View File

@@ -0,0 +1,31 @@
[ignore]
.*/react/node_modules/.*
.*/electron/node_modules/.*
.*node_modules/archiver-utils
.*node_modules/babel.*
.*node_modules/browserify-zlib/.*
.*node_modules/classnames.*
.*node_modules/gh-pages/.*
.*node_modules/invariant/.*
.*node_modules/json-loader.*
.*node_modules/json5.*
.*node_modules/node-libs-browser.*
.*node_modules/webpack.*
.*node_modules/fbjs/flow.*
.*node_modules/web-ext.*
[include]
[libs]
./flow.js
[lints]
[options]
esproposal.class_instance_fields=enable
suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue
suppress_comment=\\(.\\|\n\\)*\\$FlowIgnore
module.name_mapper='^src' ->'<PROJECT_ROOT>/src'
[strict]

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
/shells/chrome.crx
/shells/chrome.pem
/shells/firefox/*.xpi
build
node_modules
npm-debug.log
yarn-error.log
.DS_Store
yarn-error.log

0
README.md Normal file
View File

11
flow.js Normal file
View File

@@ -0,0 +1,11 @@
// @flow
declare module 'events' {
declare class EventEmitter {
addListener: (type: string, fn: Function) => void;
emit: (type: string, data: any) => void;
removeListener: (type: string, fn: Function) => void;
}
declare export default typeof EventEmitter;
}

80
package.json Normal file
View File

@@ -0,0 +1,80 @@
{
"dependencies": {
"@babel/core": "^7.1.6",
"@babel/plugin-proposal-class-properties": "^7.1.0",
"@babel/plugin-transform-flow-strip-types": "^7.1.6",
"@babel/preset-env": "^7.1.6",
"@babel/preset-flow": "^7.0.0",
"@babel/preset-react": "^7.0.0",
"adm-zip": "^0.4.7",
"babel-core": "^7.0.0-bridge",
"babel-eslint": "6",
"babel-jest": "^23.6.0",
"babel-loader": "^8.0.4",
"child-process-promise": "^2.2.1",
"classnames": "2.2.1",
"cli-spinners": "^1.0.0",
"clipboard-js": "^0.3.3",
"css-loader": "^1.0.1",
"error-stack-parser": "^2.0.2",
"es6-symbol": "3.0.2",
"eslint": "2.10.2",
"eslint-plugin-react": "5.1.1",
"events": "^3.0.0",
"fbjs": "0.5.1",
"fbjs-scripts": "0.7.0",
"flow-bin": "^0.91.0",
"fs-extra": "^3.0.1",
"gh-pages": "^1.0.0",
"immutable": "3.7.6",
"jest": "22.1.4",
"log-update": "^2.0.0",
"lru-cache": "^4.1.3",
"memoize-one": "^3.1.1",
"node-libs-browser": "0.5.3",
"nullthrows": "^1.0.0",
"object-assign": "4.0.1",
"prop-types": "^15.6.1",
"react": "^16.8.0-alpha.1",
"react-color": "^2.11.7",
"react-dom": "^16.8.0-alpha.1",
"react-portal": "^3.1.0",
"react-virtualized-auto-sizer": "^1.0.2",
"react-window": "^1.1.1",
"semver": "^5.5.1",
"style-loader": "^0.23.1",
"webpack": "^4.26.0",
"webpack-cli": "^3.1.2"
},
"license": "BSD-3-Clause",
"repository": "facebook/react-devtools",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"build:extension": "yarn run build:extension:chrome && yarn run build:extension:firefox",
"build:extension:chrome": "node ./shells/chrome/build",
"build:extension:firefox": "node ./shells/firefox/build",
"build:standalone": "cd packages/react-devtools-core && yarn run build",
"deploy": "cd ./shells/theme-preview && ./build.sh && gh-pages -d .",
"lint": "eslint .",
"start": "cd ./shells/dev && open ./index.html && webpack --config webpack.config.js --watch",
"test": "jest",
"test:chrome": "node ./shells/chrome/test",
"test:firefox": "node ./shells/firefox/test",
"test:standalone": "cd packages/react-devtools && yarn start",
"typecheck": "flow check"
},
"jest": {
"modulePathIgnorePatterns": [
"<rootDir>/shells"
]
},
"devDependencies": {
"chrome-launch": "^1.1.4",
"firefox-profile": "^1.0.2",
"lerna": "^2.8.0",
"web-ext": "^1.10.1"
}
}

View File

@@ -0,0 +1,6 @@
{
"name": "React v16 DevTools",
"version": "0.1",
"description": "DevTools for React version 16.0+",
"manifest_version": 2
}

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
*/
'use strict';
const {readFileSync} = require('fs');
const {resolve} = require('path');
const __DEV__ = process.env.NODE_ENV !== 'production';
module.exports = {
mode: __DEV__ ? 'development' : 'production',
devtool: __DEV__ ? 'cheap-module-eval-source-map' : false,
entry: {
backend: './src/backend.js',
},
output: {
path: __dirname + '/build',
filename: '[name].js',
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
options: JSON.parse(readFileSync(resolve(__dirname, '../../.babelrc'))),
},
],
},
};

View File

@@ -0,0 +1,63 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
*/
'use strict';
const {readFileSync} = require('fs');
const {resolve} = require('path');
const webpack = require('webpack');
const __DEV__ = process.env.NODE_ENV !== 'production';
module.exports = {
mode: __DEV__ ? 'development' : 'production',
devtool: __DEV__ ? 'cheap-module-eval-source-map' : false,
entry: {
background: './src/background.js',
contentScript: './src/contentScript.js',
inject: './src/GlobalHook.js',
main: './src/main.js',
panel: './src/panel.js',
},
output: {
path: __dirname + '/build',
filename: '[name].js',
},
plugins: __DEV__ ? [] : [
// Ensure we get production React
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"production"',
}),
],
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
options: JSON.parse(readFileSync(resolve(__dirname, '../../.babelrc'))),
},
{
test: /\.css$/,
use: [
{
loader: 'style-loader',
},
{
loader: 'css-loader',
options: {
sourceMap: true,
modules: true,
localIdentName: '[local]___[hash:base64:5]',
},
},
],
},
],
},
};

150
shells/createConfig.js Normal file
View File

@@ -0,0 +1,150 @@
const path = require('path')
const webpack = require('webpack')
const merge = require('webpack-merge')
const { VueLoaderPlugin } = require('vue-loader')
const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
module.exports = (config, target = { chrome: 52, firefox: 48 }) => {
const bubleOptions = {
target,
objectAssign: 'Object.assign',
transforms: {
forOf: false,
modules: false
}
}
const baseConfig = {
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
resolve: {
alias: {
src: path.resolve(__dirname, '../src'),
views: path.resolve(__dirname, '../src/devtools/views'),
components: path.resolve(__dirname, '../src/devtools/components'),
filters: path.resolve(__dirname, '../src/devtools/filters')
}
},
module: {
rules: [
{
test: /\.js$/,
loader: 'buble-loader',
exclude: /node_modules|vue\/dist|vuex\/dist/,
options: bubleOptions
},
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
compilerOptions: {
preserveWhitespace: false
},
transpileOptions: bubleOptions
}
},
{
test: /\.css$/,
use: [
'vue-style-loader',
'css-loader',
'postcss-loader'
]
},
{
test: /\.styl(us)?$/,
use: [
'vue-style-loader',
'css-loader',
'postcss-loader',
'stylus-loader',
{
loader: 'style-resources-loader',
options: {
patterns: [
path.resolve(__dirname, '../src/devtools/style/imports.styl')
]
}
}
]
},
{
test: /\.(png|woff2)$/,
loader: 'url-loader?limit=0'
}
]
},
performance: {
hints: false
},
plugins: [
new VueLoaderPlugin(),
...(process.env.VUE_DEVTOOL_TEST ? [] : [new FriendlyErrorsPlugin()]),
new webpack.DefinePlugin({
'process.env.RELEASE_CHANNEL': JSON.stringify(process.env.RELEASE_CHANNEL || 'stable')
})
],
devServer: {
port: process.env.PORT
},
stats: {
colors: true
}
}
if (process.env.NODE_ENV === 'production') {
const UglifyPlugin = require('uglifyjs-webpack-plugin')
baseConfig.plugins.push(
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"production"'
})
)
baseConfig.optimization = {
minimizer: [
new UglifyPlugin({
exclude: /backend/,
uglifyOptions: {
compress: {
// turn off flags with small gains to speed up minification
arrows: false,
collapse_vars: false, // 0.3kb
comparisons: false,
computed_props: false,
hoist_funs: false,
hoist_props: false,
hoist_vars: false,
inline: false,
loops: false,
negate_iife: false,
properties: false,
reduce_funcs: false,
reduce_vars: false,
switches: false,
toplevel: false,
typeofs: false,
// a few flags with noticable gains/speed ratio
// numbers based on out of the box vendor bundle
booleans: true, // 0.7kb
if_return: true, // 0.4kb
sequences: true, // 0.7kb
unused: true, // 2.3kb
// required features to drop conditional branches
conditionals: true,
dead_code: true,
evaluate: true
},
mangle: {
safari10: true
}
},
sourceMap: false,
cache: true,
parallel: true
})
]
}
}
return merge(baseConfig, config)
}

6
shells/dev/app/App.css Normal file
View File

@@ -0,0 +1,6 @@
.App {
/* GitHub.com frontend fonts */
font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol;
font-size: 14px;
line-height: 1.5;
}

View File

@@ -0,0 +1,54 @@
// @flow
import React, {
createContext,
forwardRef,
lazy,
memo,
Component,
ConcurrentMode,
Fragment,
Profiler,
StrictMode,
Suspense,
} from 'react';
class ClassComponent extends Component {
render() {
return null;
}
}
function FunctionComponent() {
return null;
}
const MemoFunctionComponent = memo(FunctionComponent);
const ForwardRefComponent = forwardRef((props, ref) => (
<ClassComponent ref={ref} {...props} />
));
const LazyComponent = lazy(() => Promise.resolve({
default: FunctionComponent,
}));
export default function ElementTypes() {
return (
<Profiler id="test" onRender={() => {}}>
<Fragment>
<StrictMode>
<ConcurrentMode>
<Suspense fallback={<div>Loading...</div>}>
<ClassComponent />
<FunctionComponent />
<MemoFunctionComponent />
<ForwardRefComponent />
<LazyComponent />
</Suspense>
</ConcurrentMode>
</StrictMode>
</Fragment>
</Profiler>
)
}

View File

@@ -0,0 +1,22 @@
.Header {
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.Input {
font-size: 1rem;
padding: 0.25rem;
}
.IconButton {
padding: 0.25rem;
border: none;
background: none;
cursor: pointer;
}
.List {
margin: 0.5rem 0 0;
padding: 0;
}

View File

@@ -0,0 +1,94 @@
// @flow
import React, { Fragment, useCallback, useState } from 'react';
import ListItem from './ListItem';
import styles from './List.css';
export type Item = {|
id: number,
isComplete: boolean,
text: string,
|};
type Props = {||};
export default function List({}: Props) {
const [newItemText, setNewItemText] = useState<string>('');
const [items, setItems] = useState<Array<Item>>([
{id: 1, isComplete: true, text: "First"},
{id: 2, isComplete: true, text: "Second"},
{id: 3, isComplete: false, text: "Third"},
]);
const [uid, setUID] = useState<number>(4);
const handleClick = useCallback(() => {
if (newItemText !== '') {
setItems([...items, {
id: uid,
isComplete: false,
text: newItemText,
}]);
setUID(uid + 1);
setNewItemText('');
}
}, [newItemText]);
const handleKeyPress = useCallback(event => {
if (event.key === 'Enter') {
handleClick();
}
}, [handleClick]);
const handleChange = useCallback(event => {
setNewItemText(event.currentTarget.value);
}, [setNewItemText]);
const removeItem = useCallback(itemToRemove => {
setItems(
items.filter(item => item !== itemToRemove)
);
}, [items]);
const toggleItem = useCallback(itemToToggle => {
const index = items.indexOf(itemToToggle);
setItems(
items
.slice(0, index)
.concat({
...itemToToggle,
isComplete: !itemToToggle.isComplete,
})
.concat(items.slice(index + 1))
);
}, [items]);
return (
<Fragment>
<div className={styles.Header}>List</div>
<input
type="text"
placeholder="New list item..."
className={styles.Input}
value={newItemText}
onChange={handleChange}
onKeyPress={handleKeyPress}
/>
<button
className={styles.IconButton}
disabled={newItemText === ''}
onClick={handleClick}
></button>
<ul className={styles.List}>
{items.map(item => (
<ListItem
key={item.id}
item={item}
removeItem={removeItem}
toggleItem={toggleItem}
/>
))}
</ul>
</Fragment>
);
}

View File

@@ -0,0 +1,23 @@
.ListItem {
list-style-type: none;
}
.Input {
cursor: pointer;
}
.Label {
cursor: pointer;
padding: 0.25rem;
color: #555;
}
.Label:hover {
color: #000;
}
.IconButton {
padding: 0.25rem;
border: none;
background: none;
cursor: pointer;
}

View File

@@ -0,0 +1,36 @@
// @flow
import React, { Fragment, useCallback, useState } from 'react';
import styles from './ListItem.css';
import type {Item} from './List';
type Props = {|
item: Item,
removeItem: (item: Item) => void,
toggleItem: (item: Item) => void,
|};
export default function ListItem({ item, removeItem, toggleItem }: Props) {
const handleDelete = useCallback(() => {
removeItem(item);
}, [item, removeItem]);
const handleToggle = useCallback(() => {
toggleItem(item);
}, [item, toggleItem]);
return (
<li className={styles.ListItem}>
<button className={styles.IconButton} onClick={handleDelete}>🗑</button>
<label className={styles.Label}>
<input
className={styles.Input}
checked={item.isComplete}
onChange={handleToggle}
type="checkbox"
/> {item.text}
</label>
</li>
)
}

View File

@@ -0,0 +1,5 @@
// @flow
import List from './List';
export default List;

15
shells/dev/app/app.js Normal file
View File

@@ -0,0 +1,15 @@
// @flow
import React from 'react';
import List from './ToDoList';
import ElementTypes from './ElementTypes';
import styles from './App.css';
export default function App() {
return (
<div className={styles.App}>
<List />
<ElementTypes />
</div>
)
}

11
shells/dev/app/index.js Normal file
View File

@@ -0,0 +1,11 @@
/** @flow */
import { createElement } from 'react';
import { render } from 'react-dom';
import App from './App';
const container = document.createElement('div');
render(createElement(App), container);
((document.body: any): HTMLBodyElement).appendChild(container);

44
shells/dev/index.html Normal file
View File

@@ -0,0 +1,44 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
<title>React DevTools</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
#target {
flex: 1;
border: none;
border-bottom: 1px solid #ccc;
}
#devtools {
display: flex;
height: 400px;
max-height: 50%;
overflow: hidden;
}
body {
display: flex;
flex-direction: column;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<!-- React test app (shells/dev/app) is injected here -->
<!-- DevTools backend (shells/dev/src) is injected here -->
<!-- global "hook" is defined on the iframe's contentWindow -->
<iframe id="target"></iframe>
<!-- DevTools frontend UI (shells/dev/src) renders here -->
<div id="devtools"></div>
<!-- This script installs the hook, injects the backend, and renders the DevTools UI -->
<script src="build/devtools.js"></script>
</body>
</html>

21
shells/dev/src/backend.js Normal file
View File

@@ -0,0 +1,21 @@
/** @flow */
import Agent from 'src/backend/agent';
import Bridge from 'src/bridge'
import { initBackend } from 'src/backend';
const bridge = new Bridge({
listen (fn) {
window.addEventListener('message', event => {
fn(event.data);
});
},
send (data) {
window.parent.postMessage(data, '*')
}
})
const agent = new Agent();
agent.addBridge(bridge);
initBackend(window.__REACT_DEVTOOLS_GLOBAL_HOOK__, agent);

View File

@@ -0,0 +1,52 @@
/** @flow */
import { createElement } from 'react';
import {render } from 'react-dom';
import Bridge from 'src/bridge';
import { installHook } from 'src/hook';
import { initDevTools } from 'src/devtools';
import App from 'src/devtools/views/App';
const iframe = ((document.getElementById('target'): any): HTMLIFrameElement);
const {contentDocument, contentWindow} = iframe;
installHook(contentWindow);
initDevTools({
connect(cb) {
inject('./build/backend.js', () => {
const bridge = new Bridge({
listen(fn) {
contentWindow.parent.addEventListener('message', ({ data }) => {
fn(data)
});
},
send(data) {
contentWindow.postMessage(data, '*');
}
});
cb(bridge);
render(
createElement(App, {bridge}),
((document.getElementById('devtools'): any): HTMLElement),
);
});
},
onReload(reloadFn) {
iframe.onload = reloadFn;
}
});
inject('./build/app.js');
function inject(sourcePath, callback) {
const script = contentDocument.createElement('script')
script.onload = callback;
script.src = sourcePath;
((contentDocument.body: any): HTMLBodyElement).appendChild(script);
}

View File

@@ -0,0 +1,48 @@
const {readFileSync} = require('fs');
const {resolve} = require('path');
// TODO Share Webpack configs like alias
module.exports = {
mode: 'development',
devtool: false,
entry: {
app: './app/index.js',
backend: './src/backend.js',
devtools: './src/devtools.js',
},
output: {
path: __dirname + '/build',
filename: '[name].js',
},
resolve: {
alias: {
src: resolve(__dirname, '../../src'),
},
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
options: JSON.parse(readFileSync(resolve(__dirname, '../../.babelrc'))),
},
{
test: /\.css$/,
use: [
{
loader: 'style-loader',
},
{
loader: 'css-loader',
options: {
sourceMap: true,
modules: true,
localIdentName: '[local]___[hash:base64:5]',
},
},
],
},
],
},
};

159
src/backend/agent.js Normal file
View File

@@ -0,0 +1,159 @@
// @flow
import nullthrows from 'nullthrows';
import EventEmitter from 'events';
import {guid} from './utils';
import {ElementTypeOtherOrUnknown} from 'src/devtools/types';
import type {Fiber, RendererData, RendererID, RendererInterface} from './types';
import type {Bridge} from '../types';
import type {Element} from 'src/devtools/types';
export default class Agent extends EventEmitter {
fiberToID: WeakMap<Fiber, string> = new WeakMap();
idToElement: Map<string, Element> = new Map();
idToFiber: Map<string, Fiber> = new Map();
idToRendererData: Map<string, RendererData> = new Map();
idToRendererID: Map<string, RendererID> = new Map();
rendererInterfaces: {[key: RendererID]: RendererInterface} = {};
roots: Set<RendererID> = new Set();
addBridge(bridge: Bridge) {
// TODO Listen to bridge for things like selection.
// bridge.on('...'), this...);
this.addListener('root', id => bridge.send('root', id));
this.addListener('mount', data => bridge.send('mount', data));
this.addListener('update', data => bridge.send('update', data));
this.addListener('unmount', data => bridge.send('unmount', data));
// TODO Add other methods for e.g. profiling.
}
setRendererInterface(rendererID: RendererID, rendererInterface: RendererInterface) {
this.rendererInterfaces[rendererID] = rendererInterface;
}
_getId(fiber: Fiber): string {
if (typeof fiber !== 'object' || !fiber) {
return fiber;
}
if (!this.fiberToID.has(fiber)) {
this.fiberToID.set(fiber, guid());
this.idToFiber.set(
nullthrows(this.fiberToID.get(fiber)),
fiber
);
}
return nullthrows(this.fiberToID.get(fiber));
}
_crawl(parent: Element, id: string): void {
const data: RendererData = ((this.idToRendererData.get(id): any): RendererData);
if (data.type === ElementTypeOtherOrUnknown) {
data.children.forEach(childFiber => {
if (childFiber !== null) {
this._crawl(parent, this._getId(childFiber))
}
});
} else {
parent.children.push(id);
this._createOrUpdateElement(id, data);
}
}
_createOrUpdateElement(id: string, data: RendererData): void {
const prevElement: ?Element = this.idToElement.get(id);
const nextElement: Element = {
id,
key: data.key,
displayName: data.displayName,
children: [],
type: data.type,
};
this.idToElement.set(id, nextElement);
data.children.forEach(childFiber => {
if (childFiber !== null) {
this._crawl(nextElement, this._getId(childFiber))
}
});
if (prevElement == null) {
console.log('%cAgent%c emit("mount")', 'color: blue; font-weight: bold;', 'font-weight: bold;', id, nextElement)
this.emit('mount', nextElement);
} else if (!areElementsEqual(prevElement, nextElement)) {
console.log('%cAgent%c emit("update")', 'color: blue; font-weight: bold;', 'font-weight: bold;', id, nextElement)
this.emit('update', nextElement);
}
}
onHookMount = ({data, fiber, renderer}: {data: RendererData, fiber: Fiber, renderer: RendererID}) => {
const id = this._getId(fiber);
this.idToRendererData.set(id, data);
this.idToRendererID.set(id, renderer);
};
onHookRootCommitted = ({data, fiber, renderer}: {data: RendererData, fiber: Fiber, renderer: RendererID}) => {
const id = this._getId(fiber);
console.log('%cAgent%c onHookRootCommitted()', 'color: blue; font-weight: bold;', 'font-weight: bold;', id);
this._createOrUpdateElement(id, data);
if (!this.roots.has(id)) {
this.roots.add(id);
this.emit('root', id);
}
};
onHookUnmount = ({fiber}: {fiber: Fiber}) => {
const id = this._getId(fiber);
if (this.roots.has(id)) {
this.roots.delete(id);
this.emit('rootUnmounted', id);
}
if (this.idToElement.has(id)) {
this.idToElement.delete(id);
console.log('%cAgent%c emit("unmount")', 'color: blue; font-weight: bold;', 'font-weight: bold;', id)
this.emit('unmount', id);
}
this.fiberToID.delete(fiber);
this.idToRendererData.delete(id);
this.idToRendererID.delete(id);
};
onHookUpdate = ({data, fiber}: {data: RendererData, fiber: Fiber}) => {
const id = this._getId(fiber);
this.idToRendererData.set(id, data);
};
}
function areElementsEqual(prevElement: ?Element, nextElement: ?Element): boolean {
if (!prevElement || !nextElement) {
return false;
}
if (
prevElement.key !== nextElement.key ||
prevElement.displayName !== nextElement.displayName ||
prevElement.children.length !== nextElement.children.length ||
prevElement.type !== nextElement.type
) {
return false;
}
for (let i = 0; i < prevElement.children.length; i++) {
if (prevElement.children[i] !== nextElement.children[i]) {
return false;
}
}
return true;
}

51
src/backend/index.js Normal file
View File

@@ -0,0 +1,51 @@
// @flow
import type {Hook} from './types';
import type {Bridge} from '../types';
import Agent from './agent';
import { attach } from './renderer';
export function initBackend(hook: Hook, agent: Agent): void {
const subs = [
hook.sub('renderer-attached', ({id, renderer, rendererInterface}) => {
agent.setRendererInterface(id, rendererInterface);
rendererInterface.walkTree();
}),
hook.sub('rootCommitted', agent.onHookRootCommitted),
hook.sub('mount', agent.onHookMount),
hook.sub('update', agent.onHookUpdate),
hook.sub('unmount', agent.onHookUnmount),
// TODO Add additional subscriptions required for profiling mode
];
const attachRenderer = (id, renderer) => {
const rendererInterface = attach(hook, id, renderer);
hook.rendererInterfaces[id] = rendererInterface;
hook.emit('renderer-attached', {
id,
renderer,
rendererInterface
});
};
// Connect renderers that have already injected themselves.
for (let id in hook.renderers) {
const renderer = hook.renderers[id];
attachRenderer(id, renderer);
}
// Connect any new renderers that injected themselves.
hook.on('renderer', ({id, renderer}) => {
attachRenderer(id, renderer);
});
hook.emit('react-devtools', agent);
hook.reactDevtoolsAgent = agent;
agent.addListener('shutdown', () => {
subs.forEach(fn => fn());
hook.reactDevtoolsAgent = null;
});
};

718
src/backend/renderer.js Normal file
View File

@@ -0,0 +1,718 @@
// @flow
import {gte} from 'semver';
import {
ElementTypeClassOrFunction,
ElementTypeContext,
ElementTypeForwardRef,
ElementTypeMemo,
ElementTypeOtherOrUnknown,
ElementTypeProfiler,
ElementTypeSuspense,
} from 'src/devtools/types';
import { getDisplayName } from './utils';
import type {Fiber, Hook, ReactRenderer, RendererData, RendererInterface} from './types';
// TODO: If we're profiling, process update
// TODO: Throttle process update to app tree
// Taken from ReactElement.
function resolveDefaultProps(Component: any, baseProps: Object): Object {
if (Component && Component.defaultProps) {
// Resolve default props. Taken from ReactElement
const props = Object.assign({}, baseProps);
const defaultProps = Component.defaultProps;
for (const propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
return props;
}
return baseProps;
}
function getInternalReactConstants(version) {
const ReactSymbols = {
CONCURRENT_MODE_NUMBER: 0xeacf,
CONCURRENT_MODE_SYMBOL_STRING: 'Symbol(react.concurrent_mode)',
DEPRECATED_ASYNC_MODE_SYMBOL_STRING: 'Symbol(react.async_mode)',
CONTEXT_CONSUMER_NUMBER: 0xeace,
CONTEXT_CONSUMER_SYMBOL_STRING: 'Symbol(react.context)',
CONTEXT_PROVIDER_NUMBER: 0xeacd,
CONTEXT_PROVIDER_SYMBOL_STRING: 'Symbol(react.provider)',
FORWARD_REF_NUMBER: 0xead0,
FORWARD_REF_SYMBOL_STRING: 'Symbol(react.forward_ref)',
MEMO_NUMBER: 0xead3,
MEMO_SYMBOL_STRING: 'Symbol(react.memo)',
PROFILER_NUMBER: 0xead2,
PROFILER_SYMBOL_STRING: 'Symbol(react.profiler)',
STRICT_MODE_NUMBER: 0xeacc,
STRICT_MODE_SYMBOL_STRING: 'Symbol(react.strict_mode)',
SUSPENSE_NUMBER: 0xead1,
SUSPENSE_SYMBOL_STRING: 'Symbol(react.suspense)',
DEPRECATED_PLACEHOLDER_SYMBOL_STRING: 'Symbol(react.placeholder)',
};
const ReactTypeOfSideEffect = {
PerformedWork: 1,
};
let ReactTypeOfWork;
// **********************************************************
// The section below is copied from files in React repo.
// Keep it in sync, and add version guards if it changes.
// **********************************************************
if (gte(version, '16.6.0-beta.0')) {
ReactTypeOfWork = {
ClassComponent: 1,
ContextConsumer: 9,
ContextProvider: 10,
CoroutineComponent: -1, // Removed
CoroutineHandlerPhase: -1, // Removed
ForwardRef: 11,
Fragment: 7,
FunctionComponent: 0,
HostComponent: 5,
HostPortal: 4,
HostRoot: 3,
HostText: 6,
IncompleteClassComponent: 17,
IndeterminateComponent: 2,
LazyComponent: 16,
MemoComponent: 14,
Mode: 8,
Profiler: 12,
SimpleMemoComponent: 15,
SuspenseComponent: 13,
YieldComponent: -1, // Removed
};
} else if (gte(version, '16.4.3-alpha')) {
ReactTypeOfWork = {
ClassComponent: 2,
ContextConsumer: 11,
ContextProvider: 12,
CoroutineComponent: -1, // Removed
CoroutineHandlerPhase: -1, // Removed
ForwardRef: 13,
Fragment: 9,
FunctionComponent: 0,
HostComponent: 7,
HostPortal: 6,
HostRoot: 5,
HostText: 8,
IncompleteClassComponent: -1, // Doesn't exist yet
IndeterminateComponent: 4,
LazyComponent: -1, // Doesn't exist yet
MemoComponent: -1, // Doesn't exist yet
Mode: 10,
Profiler: 15,
SimpleMemoComponent: -1, // Doesn't exist yet
SuspenseComponent: 16,
YieldComponent: -1, // Removed
};
} else {
ReactTypeOfWork = {
ClassComponent: 2,
ContextConsumer: 12,
ContextProvider: 13,
CoroutineComponent: 7,
CoroutineHandlerPhase: 8,
ForwardRef: 14,
Fragment: 10,
FunctionComponent: 1,
HostComponent: 5,
HostPortal: 4,
HostRoot: 3,
HostText: 6,
IncompleteClassComponent: -1, // Doesn't exist yet
IndeterminateComponent: 0,
LazyComponent: -1, // Doesn't exist yet
MemoComponent: -1, // Doesn't exist yet
Mode: 11,
Profiler: 15,
SimpleMemoComponent: -1, // Doesn't exist yet
SuspenseComponent: 16,
YieldComponent: 9,
};
}
// **********************************************************
// End of copied code.
// **********************************************************
return {
ReactTypeOfWork,
ReactSymbols,
ReactTypeOfSideEffect,
};
}
export function attach(
hook: Hook,
rendererID: string,
renderer: ReactRenderer,
): RendererInterface {
const {overrideProps} = renderer;
const {ReactTypeOfWork, ReactSymbols, ReactTypeOfSideEffect} = getInternalReactConstants(renderer.version);
const {PerformedWork} = ReactTypeOfSideEffect;
const {
FunctionComponent,
ClassComponent,
ContextConsumer,
Fragment,
ForwardRef,
HostRoot,
HostPortal,
HostComponent,
HostText,
IncompleteClassComponent,
IndeterminateComponent,
MemoComponent,
SimpleMemoComponent,
} = ReactTypeOfWork;
const {
CONCURRENT_MODE_NUMBER,
CONCURRENT_MODE_SYMBOL_STRING,
DEPRECATED_ASYNC_MODE_SYMBOL_STRING,
CONTEXT_CONSUMER_NUMBER,
CONTEXT_CONSUMER_SYMBOL_STRING,
CONTEXT_PROVIDER_NUMBER,
CONTEXT_PROVIDER_SYMBOL_STRING,
PROFILER_NUMBER,
PROFILER_SYMBOL_STRING,
STRICT_MODE_NUMBER,
STRICT_MODE_SYMBOL_STRING,
SUSPENSE_NUMBER,
SUSPENSE_SYMBOL_STRING,
DEPRECATED_PLACEHOLDER_SYMBOL_STRING,
} = ReactSymbols;
const primaryFibers: WeakSet<Fiber> = new WeakSet();
// TODO: we might want to change the data structure
// once we no longer suppport Stack versions of `getData`.
// TODO: Keep in sync with getElementType()
function getRendererDataFromFiber(fiber: Fiber): RendererData {
const { elementType, type, key, tag } = fiber;
// This is to support lazy components with a Promise as the type.
// see https://github.com/facebook/react/pull/13397
let resolvedType = type;
if (typeof type === 'object' && type !== null) {
if (typeof type.then === 'function') {
resolvedType = type._reactResult;
}
}
// Suspense has some special behavior when timed out that we have to handle.
// see https://github.com/facebook/react/pull/13823
let isTimedOutSuspense: boolean = false;
let rendererData: RendererData = ((null: any): RendererData);
let displayName: string = ((null: any): string);
let resolvedContext: any = null;
switch (tag) {
case ClassComponent:
case FunctionComponent:
case IncompleteClassComponent:
case IndeterminateComponent:
rendererData = {
children: [],
displayName: getDisplayName(resolvedType),
key,
type: ElementTypeClassOrFunction,
};
break;
case ForwardRef:
const functionName = getDisplayName(resolvedType.render, '');
displayName = resolvedType.displayName || (
functionName !== ''
? `ForwardRef(${functionName})`
: 'ForwardRef'
);
rendererData = {
children: [],
displayName,
key,
type: ElementTypeForwardRef,
};
break;
case HostRoot:
case HostPortal:
case HostComponent:
case HostText:
case Fragment:
// Not displayed in Elements tree
rendererData = {
children: [],
displayName: null,
key,
type: ElementTypeOtherOrUnknown,
};
break;
case MemoComponent:
case SimpleMemoComponent:
if (elementType.displayName) {
displayName = elementType.displayName;
} else {
displayName = type.displayName || type.name;
displayName = displayName ? `Memo(${displayName})` : 'Memo';
}
rendererData = {
children: [],
displayName,
key,
type: ElementTypeMemo,
};
break;
default:
const symbolOrNumber = typeof type === 'object' && type !== null
? type.$$typeof
: type;
// $FlowFixMe facebook/flow/issues/2362
const switchValue = typeof symbolOrNumber === 'symbol'
? symbolOrNumber.toString()
: symbolOrNumber;
switch (switchValue) {
case CONCURRENT_MODE_NUMBER:
case CONCURRENT_MODE_SYMBOL_STRING:
case DEPRECATED_ASYNC_MODE_SYMBOL_STRING:
// Not displayed in Elements tree
rendererData = {
children: [],
displayName: null,
key,
type: ElementTypeOtherOrUnknown,
};
break;
case CONTEXT_PROVIDER_NUMBER:
case CONTEXT_PROVIDER_SYMBOL_STRING:
// 16.3.0 exposed the context object as "context"
// PR #12501 changed it to "_context" for 16.3.1+
resolvedContext = fiber.type._context || fiber.type.context;
displayName = `${resolvedContext.displayName || 'Context'}.Provider`;
rendererData = {
children: [],
displayName,
key,
type: ElementTypeContext,
};
break;
case CONTEXT_CONSUMER_NUMBER:
case CONTEXT_CONSUMER_SYMBOL_STRING:
// 16.3-16.5 read from "type" because the Consumer is the actual context object.
// 16.6+ should read from "type._context" because Consumer can be different (in DEV).
resolvedContext = fiber.type._context || fiber.type;
// NOTE: TraceUpdatesBackendManager depends on the name ending in '.Consumer'
// If you change the name, figure out a more resilient way to detect it.
displayName = `${resolvedContext.displayName || 'Context'}.Consumer`;
rendererData = {
children: [],
displayName,
key,
type: ElementTypeContext,
};
break;
case STRICT_MODE_NUMBER:
case STRICT_MODE_SYMBOL_STRING:
rendererData = {
children: [],
displayName: null,
key,
type: ElementTypeOtherOrUnknown,
};
break;
case SUSPENSE_NUMBER:
case SUSPENSE_SYMBOL_STRING:
case DEPRECATED_PLACEHOLDER_SYMBOL_STRING:
// Suspense components only have a non-null memoizedState if they're timed-out.
isTimedOutSuspense = fiber.memoizedState !== null;
rendererData = {
children: [],
displayName: 'Suspense',
key,
type: ElementTypeSuspense,
};
break;
case PROFILER_NUMBER:
case PROFILER_SYMBOL_STRING:
rendererData = {
children: [],
displayName: `Profiler(${fiber.memoizedProps.id})`,
key,
type: ElementTypeProfiler,
};
break;
default:
// Unknown element type.
// This may mean a new element type that has not yet been added to DevTools.
rendererData = {
children: [],
displayName: null,
key,
type: ElementTypeOtherOrUnknown,
};
break;
}
break;
}
const {children} = rendererData;
if (isTimedOutSuspense) {
// The behavior of timed-out Suspense trees is unique.
// Rather than unmount the timed out content (and possibly lose important state),
// React re-parents this content within a hidden Fragment while the fallback is showing.
// This behavior doesn't need to be observable in the DevTools though.
// It might even result in a bad user experience for e.g. node selection in the Elements panel.
// The easiest fix is to strip out the intermediate Fragment fibers,
// so the Elements panel and Profiler don't need to special case them.
const primaryChildFragment = fiber.child;
const primaryChild = primaryChildFragment.child;
const fallbackChildFragment = primaryChildFragment.sibling;
const fallbackChild = fallbackChildFragment.child;
children.push(primaryChild);
children.push(fallbackChild);
} else {
let child = fiber.child;
while (child) {
children.push(getPrimaryFiber(child));
child = child.sibling;
}
}
return rendererData;
}
// This is a slightly annoying indirection.
// It is currently necessary because DevTools wants to use unique objects as keys for instances.
// However fibers have two versions.
// We use this set to remember first encountered fiber for each conceptual instance.
function getPrimaryFiber(fiber: Fiber): Fiber {
if (primaryFibers.has(fiber)) {
return fiber;
}
const {alternate} = fiber;
if (alternate != null && primaryFibers.has(alternate)) {
return alternate;
}
primaryFibers.add(fiber);
return fiber;
}
function hasDataChanged(prevFiber: Fiber, nextFiber: Fiber): boolean {
switch (nextFiber.tag) {
case ClassComponent:
case FunctionComponent:
case ContextConsumer:
case MemoComponent:
case SimpleMemoComponent:
// For types that execute user code, we check PerformedWork effect.
// We don't reflect bailouts (either referential or sCU) in DevTools.
// eslint-disable-next-line no-bitwise
return (nextFiber.effectTag & PerformedWork) === PerformedWork;
// Note: ContextConsumer only gets PerformedWork effect in 16.3.3+
// so it won't get highlighted with React 16.3.0 to 16.3.2.
default:
// For host components and other types, we compare inputs
// to determine whether something is an update.
return (
prevFiber.memoizedProps !== nextFiber.memoizedProps ||
prevFiber.memoizedState !== nextFiber.memoizedState ||
prevFiber.ref !== nextFiber.ref
);
}
}
function haveProfilerTimesChanged(prevFiber: Fiber, nextFiber: Fiber): boolean {
return (
prevFiber.actualDuration !== undefined && // Short-circuit check for non-profiling builds
(
prevFiber.actualDuration !== nextFiber.actualDuration ||
prevFiber.actualStartTime !== nextFiber.actualStartTime ||
prevFiber.treeBaseDuration !== nextFiber.treeBaseDuration
)
);
}
let pendingEvents = [];
// TODO: Do we still need to send events like this?
function flushPendingEvents() {
const events = pendingEvents;
pendingEvents = [];
for (let i = 0; i < events.length; i++) {
const event = events[i];
hook.emit(event.type, event);
}
}
function enqueueMount(fiber) {
pendingEvents.push({
fiber: getPrimaryFiber(fiber),
data: getRendererDataFromFiber(fiber),
renderer: rendererID,
type: 'mount',
});
const isRoot = fiber.tag === HostRoot;
if (isRoot) {
pendingEvents.push({
fiber: getPrimaryFiber(fiber),
renderer: rendererID,
type: 'root',
});
}
}
function enqueueUpdateIfNecessary(fiber, hasChildOrderChanged) {
const data = getRendererDataFromFiber(fiber);
if (
!hasChildOrderChanged &&
!hasDataChanged(fiber.alternate, fiber)
) {
// If only timing information has changed, we still need to update the nodes.
// But we can do it in a faster way since we know it's safe to skip the children.
// It's also important to avoid emitting an "update" signal for the node in this case,
// Since that would indicate to the Profiler that it was part of the "commit" when it wasn't.
if (haveProfilerTimesChanged(fiber.alternate, fiber)) {
pendingEvents.push({
fiber: getPrimaryFiber(fiber),
data,
renderer: rendererID,
type: 'updateProfileTimes',
});
}
return;
}
pendingEvents.push({
fiber: getPrimaryFiber(fiber),
data,
renderer: rendererID,
type: 'update',
});
}
function enqueueUnmount(fiber) {
const isRoot = fiber.tag === HostRoot;
const primaryFiber = getPrimaryFiber(fiber);
const event = {
fiber: primaryFiber,
renderer: rendererID,
type: 'unmount',
};
if (isRoot) {
pendingEvents.push(event);
} else {
// Non-root fibers are deleted during the commit phase.
// They are deleted in the child-first order. However
// DevTools currently expects deletions to be parent-first.
// This is why we unshift deletions rather than push them.
pendingEvents.unshift(event);
}
primaryFibers.delete(primaryFiber);
}
function markRootCommitted(fiber) {
pendingEvents.push({
fiber: getPrimaryFiber(fiber),
data: getRendererDataFromFiber(fiber),
renderer: rendererID,
type: 'rootCommitted',
});
}
function mountFiber(fiber) {
// Depth-first.
// Logs mounting of children first, parents later.
let node = fiber;
outer: while (true) {
if (node.child) {
node.child.return = node;
node = node.child;
continue;
}
enqueueMount(node);
if (node == fiber) {
return;
}
if (node.sibling) {
node.sibling.return = node.return;
node = node.sibling;
continue;
}
while (node.return) {
node = node.return;
enqueueMount(node);
if (node == fiber) {
return;
}
if (node.sibling) {
node.sibling.return = node.return;
node = node.sibling;
continue outer;
}
}
return;
}
}
function updateFiber(nextFiber, prevFiber) {
// Suspense components only have a non-null memoizedState if they're timed-out.
const isTimedOutSuspense = (
nextFiber.tag === ReactTypeOfWork.SuspenseComponent &&
nextFiber.memoizedState !== null
);
if (isTimedOutSuspense) {
// The behavior of timed-out Suspense trees is unique.
// Rather than unmount the timed out content (and possibly lose important state),
// React re-parents this content within a hidden Fragment while the fallback is showing.
// This behavior doesn't need to be observable in the DevTools though.
// It might even result in a bad user experience for e.g. node selection in the Elements panel.
// The easiest fix is to strip out the intermediate Fragment fibers,
// so the Elements panel and Profiler don't need to special case them.
const primaryChildFragment = nextFiber.child;
const fallbackChildFragment = primaryChildFragment.sibling;
const fallbackChild = fallbackChildFragment.child;
// The primary, hidden child is never actually updated in this case,
// so we can skip any updates to its tree.
// We only need to track updates to the Fallback UI for now.
if (fallbackChild.alternate) {
updateFiber(fallbackChild, fallbackChild.alternate);
} else {
mountFiber(fallbackChild);
}
enqueueUpdateIfNecessary(nextFiber, false);
} else {
let hasChildOrderChanged = false;
if (nextFiber.child !== prevFiber.child) {
// If the first child is different, we need to traverse them.
// Each next child will be either a new child (mount) or an alternate (update).
let nextChild = nextFiber.child;
let prevChildAtSameIndex = prevFiber.child;
while (nextChild) {
// We already know children will be referentially different because
// they are either new mounts or alternates of previous children.
// Schedule updates and mounts depending on whether alternates exist.
// We don't track deletions here because they are reported separately.
if (nextChild.alternate) {
const prevChild = nextChild.alternate;
updateFiber(nextChild, prevChild);
// However we also keep track if the order of the children matches
// the previous order. They are always different referentially, but
// if the instances line up conceptually we'll want to know that.
if (!hasChildOrderChanged && prevChild !== prevChildAtSameIndex) {
hasChildOrderChanged = true;
}
} else {
mountFiber(nextChild);
if (!hasChildOrderChanged) {
hasChildOrderChanged = true;
}
}
// Try the next child.
nextChild = nextChild.sibling;
// Advance the pointer in the previous list so that we can
// keep comparing if they line up.
if (!hasChildOrderChanged && prevChildAtSameIndex != null) {
prevChildAtSameIndex = prevChildAtSameIndex.sibling;
}
}
// If we have no more children, but used to, they don't line up.
if (!hasChildOrderChanged && prevChildAtSameIndex != null) {
hasChildOrderChanged = true;
}
}
enqueueUpdateIfNecessary(nextFiber, hasChildOrderChanged);
}
}
function cleanup() {
// We don't patch any methods so there is no cleanup.
}
function walkTree() {
hook.getFiberRoots(rendererID).forEach(root => {
// Hydrate all the roots for the first time.
mountFiber(root.current);
markRootCommitted(root.current);
});
flushPendingEvents();
}
function handleCommitFiberUnmount(fiber) {
// This is not recursive.
// We can't traverse fibers after unmounting so instead
// we rely on React telling us about each unmount.
enqueueUnmount(fiber);
}
function handleCommitFiberRoot(root) {
const current = root.current;
const alternate = current.alternate;
if (alternate) {
// TODO: relying on this seems a bit fishy.
const wasMounted = alternate.memoizedState != null && alternate.memoizedState.element != null;
const isMounted = current.memoizedState != null && current.memoizedState.element != null;
if (!wasMounted && isMounted) {
// Mount a new root.
mountFiber(current);
} else if (wasMounted && isMounted) {
// Update an existing root.
updateFiber(current, alternate);
} else if (wasMounted && !isMounted) {
// Unmount an existing root.
enqueueUnmount(current);
}
} else {
// Mount a new root.
mountFiber(current);
}
markRootCommitted(current);
// We're done here.
flushPendingEvents();
}
// The naming is confusing.
// They deal with opaque nodes (fibers), not elements.
function getNativeFromReactElement(fiber) {
try {
const primaryFiber = fiber;
const hostInstance = renderer.findHostInstanceByFiber(primaryFiber);
return hostInstance;
} catch (err) {
// The fiber might have unmounted by now.
return null;
}
}
function getReactElementFromNative(hostInstance) {
const fiber = renderer.findFiberByHostInstance(hostInstance);
if (fiber != null) {
// TODO: type fibers.
const primaryFiber = getPrimaryFiber((fiber: any));
return primaryFiber;
}
return null;
}
return {
getNativeFromReactElement,
getReactElementFromNative,
handleCommitFiberRoot,
handleCommitFiberUnmount,
cleanup,
walkTree,
renderer,
};
}

77
src/backend/types.js Normal file
View File

@@ -0,0 +1,77 @@
// @flow
import type {ElementType} from 'src/devtools/types';
type BundleType =
| 0 // PROD
| 1; // DEV
type CompositeUpdater = {
canUpdate: boolean,
setInProps: ?(path: Array<string>, value: any) => void,
setInState: ?(path: Array<string>, value: any) => void,
setInContext: ?(path: Array<string>, value: any) => void,
};
type NativeUpdater = {
setNativeProps: ?(nativeProps: {[key: string]: any}) => void,
};
// TODO: Better type for Fiber
export type Fiber = Object;
// TODO: If it's useful for the frontend to know which types of data an Element has
// (e.g. props, state, context, hooks) then we could add a bitmask field for this
// to keep the number of attributes small.
export type RendererData = {|
key: React$Key | null,
displayName: string | null,
children: Array<Fiber>,
type: ElementType,
|};
export type Interaction = {|
name: string,
timestamp: number,
|};
export type NativeType = {};
export type RendererID = string;
export type ReactRenderer = {
findHostInstanceByFiber: (fiber: Object) => ?NativeType,
findFiberByHostInstance: (hostInstance: NativeType) => ?Fiber,
version: string,
bundleType: BundleType,
overrideProps?: ?(fiber: Object, path: Array<string | number>, value: any) => void,
};
export type RendererInterface = {
cleanup: () => void,
getNativeFromReactElement?: ?(component: Fiber) => ?NativeType,
getReactElementFromNative?: ?(component: NativeType) => ?Fiber,
handleCommitFiberRoot: (fiber: Object) => void,
renderer: ReactRenderer | null,
walkTree: () => void,
};
export type Handler = (data: any) => void;
export type Hook = {
listeners: {[key: string]: Array<Handler>},
rendererInterfaces: {[key: string]: RendererInterface},
renderers: {[key: string]: ReactRenderer},
emit: (evt: string, data: any) => void,
getFiberRoots: (rendererID : string) => Set<Object>,
inject: (renderer: ReactRenderer) => string | null,
on: (evt: string, handler: Handler) => void,
off: (evt: string, handler: Handler) => void,
reactDevtoolsAgent?: ?Object,
sub: (evt: string, handler: Handler) => () => void,
// React uses these methods.
checkDCE: (fn: Function) => void,
onCommitFiberUnmount: (rendererID: RendererID, fiber: Object) => void,
onCommitFiberRoot: (rendererID: RendererID, fiber: Object) => void,
};

47
src/backend/utils.js Normal file
View File

@@ -0,0 +1,47 @@
// @flow
const FB_MODULE_RE = /^(.*) \[from (.*)\]$/;
const cachedDisplayNames: WeakMap<Function, string> = new WeakMap();
export function getDisplayName(type: Function, fallbackName: string = 'Unknown'): string {
const nameFromCache = cachedDisplayNames.get(type);
if (nameFromCache != null) {
return nameFromCache;
}
let displayName;
// The displayName property is not guaranteed to be a string.
// It's only safe to use for our purposes if it's a string.
// github.com/facebook/react-devtools/issues/803
if (typeof type.displayName === 'string') {
displayName = type.displayName;
}
if (!displayName) {
displayName = type.name || fallbackName;
}
// Facebook-specific hack to turn "Image [from Image.react]" into just "Image".
// We need displayName with module name for error reports but it clutters the DevTools.
const match = displayName.match(FB_MODULE_RE);
if (match) {
const componentName = match[1];
const moduleName = match[2];
if (componentName && moduleName) {
if (
moduleName === componentName ||
moduleName.startsWith(componentName + '.')
) {
displayName = componentName;
}
}
}
cachedDisplayNames.set(type, displayName);
return displayName;
}
export function guid(): string {
return 'g' + Math.random().toString(16).substr(2);
}

91
src/bridge.js Normal file
View File

@@ -0,0 +1,91 @@
// @flow
import EventEmitter from 'events';
import type {Wall} from './types';
const BATCH_DURATION = 100;
type Message = {|
event: string,
payload: any,
|};
export default class Bridge extends EventEmitter {
_messageQueue: Array<Message> = [];
_time: number | null = null;
_timeoutID: TimeoutID | null = null;
wall: Wall;
constructor (wall: Wall) {
super();
this.wall = wall;
wall.listen(messages => {
if (Array.isArray(messages)) {
messages.forEach(message => this._emit(message))
} else {
this._emit(messages)
}
})
}
/**
* Send an event.
*
* @param {String} event
* @param {*} payload
*/
send (event: string, payload: any) {
const time = this._time;
if (time === null) {
this.wall.send([{ event, payload }])
this._time = Date.now()
} else {
this._messageQueue.push({
event,
payload
})
const now = Date.now()
if (now - time > BATCH_DURATION) {
this._flush()
} else {
this._timeoutID = setTimeout(() => this._flush(), BATCH_DURATION)
}
}
}
/**
* Log a message to the devtools background page.
*
* @param {String} message
*/
log (message: string): void {
this.send('log', message)
}
_flush () {
if (this._messageQueue.length) {
this.wall.send(this._messageQueue)
}
if (this._timeoutID !== null) {
clearTimeout(this._timeoutID)
this._timeoutID = null;
}
this._messageQueue = []
this._time = null
}
_emit (message: string | Message) {
if (typeof message === 'string') {
this.emit(message)
} else {
this.emit(message.event, message.payload)
}
}
}

14
src/devtools/index.js Normal file
View File

@@ -0,0 +1,14 @@
// @flow
import type { Bridge } from '../types';
type Shell = {|
connect: (callback: Function) => void,
onReload: (reloadFn: Function) => void,
|};
export function initDevTools(shell: Shell) {
shell.connect((bridge: Bridge) => {
// TODO ...
});
}

78
src/devtools/store.js Normal file
View File

@@ -0,0 +1,78 @@
// @flow
import EventEmitter from 'events';
import type { Element } from './types';
import type { Bridge } from '../types';
/**
* The store is the single source of truth for updates from the backend.
* ContextProviders can subscribe to the Store for specific things they want to provide.
*/
class Store extends EventEmitter {
_idToElement: Map<string, Element> = new Map();
_idToParentID: Map<string, string> = new Map();
roots: Set<string> = new Set();
constructor(bridge: Bridge) {
super();
bridge.on('root', this.onBridgeRoot);
bridge.on('mount', this.onBridgeMount);
bridge.on('update', this.onBridgeUpdated);
bridge.on('unmount', this.onBridgeUnmounted);
}
getElement(id: string) {
return this._idToElement.get(id);
}
geParent(id: string) {
return this._idToParentID.get(id);
}
onBridgeMount = (element: Element) => {
const {id} = element;
console.log('%cStore%c onBridgeMount()', 'color: red; font-weight: bold;', 'font-weight: bold;', element);
this._idToElement.set(id, element);
element.children.forEach(childID => {
this._idToParentID.set(childID, id);
});
this.emit(id);
};
onBridgeRoot = (id: string) => {
console.log('%cStore%c onBridgeRoot()', 'color: red; font-weight: bold;', 'font-weight: bold;', id);
if (!this.roots.has(id)) {
this.roots.add(id);
this.emit('roots');
}
};
onBridgeUnmounted = (id: string) => {
console.log('%cStore%c onBridgeUnmounted()', 'color: red; font-weight: bold;', 'font-weight: bold;', id);
this._idToElement.delete(id);
if (this._idToParentID.has(id)) {
this._idToParentID.delete(id);
}
if (this.roots.has(id)) {
this.roots.delete(id);
this.emit('roots');
}
};
onBridgeUpdated = (element: Element) => {
const {id} = element;
console.log('%cStore%c onBridgeUpdated()', 'color: red; font-weight: bold;', 'font-weight: bold;', element);
this._idToElement.set(id, element);
this.emit(id);
};
}
export default Store;

38
src/devtools/types.js Normal file
View File

@@ -0,0 +1,38 @@
// @flow
export const ElementTypeClassOrFunction = 1;
export const ElementTypeContext = 2;
export const ElementTypeForwardRef = 3;
export const ElementTypeMemo = 4;
export const ElementTypeOtherOrUnknown = 5;
export const ElementTypeProfiler = 6;
export const ElementTypeSuspense = 7;
export type ElementType =
| 1
| 2
| 3
| 4
| 5
| 6
| 7;
// TODO: Add profiling node
export type Element = {|
id: string,
type: ElementType,
key: React$Key | null,
displayName: string | null,
children: Array<string>,
|};
export type InspectedElement = {|
id: string,
context: Object | null,
hooks: Object | null,
props: Object | null,
state: Object | null,
canEditProps: boolean,
source: Object,
|};

View File

@@ -0,0 +1,49 @@
:root {
/* TODO */
--color-base00: #ffffff;
--color-base01: #f3f3f3;
--color-base02: #eeeeee;
--color-base03: #dadada;
--color-base04: #333333;
--color-base05: #5a5a5a;
--color-special00: #8155cb;
--color-special01: #222222;
--color-special02: #1a1aa6;
--color-special03: #c80000;
--color-special04: #236e25;
--color-special05: #aa0d91;
--color-special06: #ef6632;
--color-special07: #777d88;
--color-state00: #0088FA;
--color-state01: #dadada;
--color-state02: #ffffff;
--color-state03: #ebf1fb;
--color-state04: #FFFF00;
--color-state05: #222222;
--color-state06: #222222;
--color-component: var(--color-special00);
--color-tree-tag: var(--color-base04);
--color-tree-attr-name: var(--color-special06);
--color-tree-attr-value: var(--color-special02);
--line-height-tree-compact: 1.5;
--line-height-tree-normal: 1.5;
/* GitHub.com system fonts */
--font-family-monospace: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
--font-size-tree-compact: 12px;
--font-size-tree-normal: 14px;
}
.App {
width: 100%;
height: 100%;
overflow: auto;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}

25
src/devtools/views/App.js Normal file
View File

@@ -0,0 +1,25 @@
// @flow
import React, { useMemo } from 'react';
import Store from '../store';
import Tree from './Tree';
import {StoreContext} from './contexts';
import styles from './App.css';
import type {Bridge} from '../../types';
type Props = {|
bridge: Bridge,
|};
export default function App({ bridge }: Props) {
const store = useMemo(() => new Store(bridge), []);
return (
<StoreContext.Provider value={store}>
<div className={styles.App}>
<Tree />
</div>
</StoreContext.Provider>
);
}

View File

@@ -0,0 +1,46 @@
.Element {
width: 100%;
border-radius: 0.25em;
cursor: pointer;
position: relative;
}
.Element:hover {
background-color: var(--color-state03);
}
.ArrowClosed,
.ArrowOpen {
position: absolute;
margin-left: -1rem;
width: 1rem;
height: 1rem;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='%23777d88' d='M8 5v14l11-7z'/%3E%3Cpath fill='none' d='M0 24V0h24v24H0z'/%3E%3C/svg%3E");
background-size: 12px 12px;
background-repeat: no-repeat;
background-position: center;
}
.ArrowOpen {
transform: rotate(90deg);
}
.Component {
color: var(--color-component);
}
.Component:before {
white-space: nowrap;
content: "<";
color: var(--color-tree-tag);
}
.Component:after {
white-space: nowrap;
content: ">";
color: var(--color-tree-tag);
}
.AttributeName {
color: var(--color-tree-attr-name);
}
.AttributeValue {
color: var(--color-tree-attr-value);
}

View File

@@ -0,0 +1,43 @@
// @flow
import React, {Fragment, useContext} from 'react';
import {useElement} from './hooks';
import {StoreContext} from './contexts';
import styles from './Element.css';
type Props = {|
depth: number,
id: string,
|};
export default function Element({depth, id}: Props) {
const store = useContext(StoreContext);
const element = useElement(store, id);
const {children, displayName, key} = element;
// TODO: Toggle open/close state
return (
<Fragment>
<div className={styles.Element} style={{paddingLeft: `${1 + depth}rem`}}>
{children.length > 0 && (
<span className={styles.ArrowOpen}></span>
)}
<span className={styles.Component}>
{displayName}{key && (
<Fragment>
&nbsp;<span className={styles.AttributeName}>key</span>=<span className={styles.AttributeValue}>"{key}"</span>
</Fragment>
)}
</span>
</div>
{children.map(childID => (
<Element key={childID} depth={depth + 1} id={childID} />
))}
</Fragment>
);
}

View File

@@ -0,0 +1,11 @@
.Tree {
width: 100%;
padding: 0.25rem;
overflow: auto;
font-family: var(--font-family-monospace);
font-size: var(--font-size-tree-compact);
/* TODO */
line-height: var(--line-height-tree-compact);
}

View File

@@ -0,0 +1,34 @@
// @flow
import React, {useContext} from 'react';
import Element from './Element';
import {StoreContext} from './contexts';
import {useElement, useRoots} from './hooks';
import styles from './Tree.css';
type TreeProps = {||};
export default function Tree({}: TreeProps) {
const store = useContext(StoreContext);
const roots = useRoots(store);
return (
<div className={styles.Tree}>
{roots.map(id => <Root key={id} id={id} />)}
</div>
);
}
type RootProps = {|
id: string,
|};
function Root({id}: RootProps) {
const store = useContext(StoreContext);
const element = useElement(store, id);
return element.children.map(childID => (
<Element key={childID} depth={0} id={childID} />
));
}

View File

@@ -0,0 +1,8 @@
// @flow
import { createContext } from 'react';
import Store from '../store';
export const RootsContext = createContext<Array<string>>([]);
export const StoreContext = createContext<Store>(((null: any): Store));

View File

@@ -0,0 +1,30 @@
// @flow
import {createContext, useLayoutEffect, useRef, useState} from 'react';
import type {Element} from '../types';
import Store from '../store';
export function useElement(store: Store, id: string): Element {
const [element, setElement] = useState<Element>(((store.getElement(id): any): Element));
useLayoutEffect(() => {
const handler = () => setElement(((store.getElement(id): any): Element));
store.addListener(id, handler);
return () => store.removeListener(id, handler);
}, [store, id]);
return element;
}
export function useRoots(store: Store): Array<string> {
const [roots, setRoots] = useState<Array<string>>(Array.from(store.roots));
useLayoutEffect(() => {
const handler = () => setRoots(Array.from(store.roots));
store.addListener('roots', handler);
return () => store.removeListener('roots', handler);
}, [store]);
return roots;
}

182
src/hook.js Normal file
View File

@@ -0,0 +1,182 @@
/**
* Install the hook on window, which is an event emitter.
* Note because Chrome content scripts cannot directly modify the window object,
* we are evaling this function by inserting a script tag.
* That's why we have to inline the whole event emitter implementation here.
*
* @flow
*/
import type {Hook} from './types';
declare var window: any;
export function installHook(target: any): Hook {
if (target.hasOwnProperty('__REACT_DEVTOOLS_GLOBAL_HOOK__')) return
function detectReactBuildType(renderer) {
try {
if (typeof renderer.version === 'string') {
if (renderer.bundleType > 0) {
// This is not a production build.
// We are currently only using 0 (PROD) and 1 (DEV)
// but might add 2 (PROFILE) in the future.
return 'development';
}
// React 16 uses flat bundles. If we report the bundle as production
// version, it means we also minified and envified it ourselves.
return 'production';
// Note: There is still a risk that the CommonJS entry point has not
// been envified or uglified. In this case the user would have *both*
// development and production bundle, but only the prod one would run.
// This would be really bad. We have a separate check for this because
// it happens *outside* of the renderer injection. See `checkDCE` below.
}
} catch (err) {}
return 'production';
}
function checkDCE(fn: Function) {
// This runs for production versions of React.
// Needs to be super safe.
try {
const toString = Function.prototype.toString;
const code = toString.call(fn);
// This is a string embedded in the passed function under DEV-only
// condition. However the function executes only in PROD. Therefore,
// if we see it, dead code elimination did not work.
if (code.indexOf('^_^') > -1) {
// Remember to report during next injection.
hasDetectedBadDCE = true;
// Bonus: throw an exception hoping that it gets picked up by a reporting system.
// Not synchronously so that it doesn't break the calling code.
setTimeout(function() {
throw new Error(
'React is running in production mode, but dead code ' +
'elimination has not been applied. Read how to correctly ' +
'configure React for production: ' +
'https://fb.me/react-perf-use-the-production-build'
);
});
}
} catch (err) {}
}
function inject(renderer) {
const id = Math.random().toString(16).slice(2);
renderers[id] = renderer;
const reactBuildType = hasDetectedBadDCE ? 'deadcode' : detectReactBuildType(renderer);
hook.emit('renderer', {id, renderer, reactBuildType});
return id;
}
let hasDetectedBadDCE = false;
function sub(event, fn) {
hook.on(event, fn);
return () => hook.off(event, fn);
}
function on(event, fn) {
if (!listeners[event]) {
listeners[event] = [];
}
listeners[event].push(fn);
}
function off(event, fn) {
if (!listeners[event]) {
return;
}
const index = listeners[event].indexOf(fn);
if (index !== -1) {
listeners[event].splice(index, 1);
}
if (!listeners[event].length) {
delete listeners[event];
}
}
function emit(event, data) {
if (listeners[event]) {
listeners[event].map(fn => fn(data));
}
}
function getFiberRoots(rendererID) {
const roots = fiberRoots;
if (!roots[rendererID]) {
roots[rendererID] = new Set();
}
return roots[rendererID];
}
function onCommitFiberUnmount(rendererID, fiber) {
// TODO: can we use hook for roots too?
if (rendererInterfaces[rendererID]) {
rendererInterfaces[rendererID].handleCommitFiberUnmount(fiber);
}
}
function onCommitFiberRoot(rendererID, root) {
const mountedRoots = hook.getFiberRoots(rendererID);
const current = root.current;
const isKnownRoot = mountedRoots.has(root);
const isUnmounting = current.memoizedState == null || current.memoizedState.element == null;
// Keep track of mounted roots so we can hydrate when DevTools connect.
if (!isKnownRoot && !isUnmounting) {
mountedRoots.add(root);
} else if (isKnownRoot && isUnmounting) {
mountedRoots.delete(root);
}
if (rendererInterfaces[rendererID]) {
rendererInterfaces[rendererID].handleCommitFiberRoot(root);
}
}
// TODO: More meaningful names for "rendererInterfaces" and "renderers".
const fiberRoots = {};
const rendererInterfaces = {};
const listeners = {};
const renderers = {};
const hook: Hook = ({
rendererInterfaces,
listeners,
renderers,
emit,
getFiberRoots,
inject,
on,
off,
sub,
// This is a legacy flag.
// React v16 checks the hook for this to ensure DevTools is new enough.
supportsFiber: true,
// React calls these methods.
checkDCE,
onCommitFiberUnmount,
onCommitFiberRoot,
});
Object.defineProperty(target, '__REACT_DEVTOOLS_GLOBAL_HOOK__', ({
enumerable: false,
get () {
return hook
}
}: Object));
return hook;
}

12
src/types.js Normal file
View File

@@ -0,0 +1,12 @@
// @flow
// TODO
export type Bridge = any;
// TODO
export type Hook = any;
export type Wall = {|
listen: (fn: Function) => void,
send: (data: any) => void,
|};

10044
yarn.lock Normal file

File diff suppressed because it is too large Load Diff