diff --git a/agent/Bridge.js b/agent/Bridge.js index 80ce5f6c0d..387343c69b 100644 --- a/agent/Bridge.js +++ b/agent/Bridge.js @@ -156,7 +156,7 @@ class Bridge { }); } - call(name: string, args: any | Array, cb: (val: any) => any) { + call(name: string, args: Array, cb: (val: any) => any) { var _cid = this._cid++; this._cbs.set(_cid, cb); @@ -187,11 +187,20 @@ class Bridge { }); } + setInspectable(id: string, data: Object) { + var prev = this._inspectables.get(id); + if (!prev) { + this._inspectables.set(id, data); + return; + } + this._inspectables.set(id, {...prev, ...data}); + } + sendOne(evt: string, data: any) { var cleaned = []; var san = dehydrate(data, cleaned); if (cleaned.length) { - this._inspectables.set(data.id, data); + this.setInspectable(data.id, data); } this._wall.send({type: 'event', evt, data: san, cleaned}); } @@ -218,7 +227,7 @@ class Bridge { var cleaned = []; var san = dehydrate(data, cleaned); if (cleaned.length) { - this._inspectables.set(data.id, data); + this.setInspectable(data.id, data); } return {type: 'event', evt, data: san, cleaned}; }); diff --git a/frontend/Container.js b/frontend/Container.js index a1be42ba00..17b72debe8 100644 --- a/frontend/Container.js +++ b/frontend/Container.js @@ -35,6 +35,7 @@ class Container extends React.Component { store: Object ) => ?Array, }, + extraTabs: {[key: string]: () => ReactElement}, }; render(): ReactElement { @@ -50,9 +51,7 @@ class Container extends React.Component { }; return (
- +
); diff --git a/frontend/DataView/DataView.js b/frontend/DataView/DataView.js index 2ccbd45d82..e4653ad6c3 100644 --- a/frontend/DataView/DataView.js +++ b/frontend/DataView/DataView.js @@ -10,9 +10,12 @@ */ 'use strict'; +import type {DOMEvent} from '../types'; + var React = require('react'); var Simple = require('./Simple'); +var assign = require('object-assign'); var consts = require('../../agent/consts'); var previewComplex = require('./previewComplex'); @@ -20,8 +23,10 @@ class DataView extends React.Component { props: { data: Object, path: Array, - inspect: () => void, - showMenu: () => void, + inspect: (path: Array, cb: () => void) => void, + showMenu: (e: DOMEvent, val: any, path: Array, name: string) => void, + startOpen: boolean, + noSort?: boolean, readOnly: boolean, }; @@ -31,7 +36,9 @@ class DataView extends React.Component { return
null
; } var names = Object.keys(data); - names.sort(); + if (!this.props.noSort) { + names.sort(); + } var path = this.props.path; if (!names.length) { return Empty object; @@ -44,6 +51,7 @@ class DataView extends React.Component { name={'__proto__'} path={path.concat(['__proto__'])} key={'__proto__'} + startOpen={this.props.startOpen} inspect={this.props.inspect} showMenu={this.props.showMenu} readOnly={this.props.readOnly} @@ -55,6 +63,7 @@ class DataView extends React.Component { name={name} path={path.concat([name])} key={name} + startOpen={this.props.startOpen} inspect={this.props.inspect} showMenu={this.props.showMenu} readOnly={this.props.readOnly} @@ -69,7 +78,13 @@ class DataView extends React.Component { class DataItem extends React.Component { constructor(props) { super(props); - this.state = {open: false, loading: false}; + this.state = {open: this.props.startOpen, loading: false}; + } + + componentDidMount() { + if (this.state.open && this.props.value && this.props.value[consts.inspected] === false) { + this.inspect(); + } } componentWillReceiveProps(nextProps) { @@ -157,7 +172,8 @@ class DataItem extends React.Component {
{opener}
{this.props.name}:
@@ -218,6 +234,10 @@ var styles = { margin: '2px 3px', }, + complexName: { + cursor: 'pointer', + }, + preview: { display: 'flex', margin: '2px 3px', diff --git a/frontend/DataView/Simple.js b/frontend/DataView/Simple.js index 1760493093..7123b35923 100644 --- a/frontend/DataView/Simple.js +++ b/frontend/DataView/Simple.js @@ -141,7 +141,7 @@ Simple.propTypes = { }; Simple.contextTypes = { - onChange: React.PropTypes.func.isRequired, + onChange: React.PropTypes.func, }; var styles = { diff --git a/frontend/Panel.js b/frontend/Panel.js index 6377cf4f07..a38e38844d 100644 --- a/frontend/Panel.js +++ b/frontend/Panel.js @@ -15,9 +15,11 @@ var Container = require('./Container'); var Store = require('./Store'); var keyboardNav = require('./keyboardNav'); var invariant = require('./invariant'); +var assign = require('object-assign'); var Bridge = require('../agent/Bridge'); var NativeStyler = require('../plugins/ReactNativeStyle/ReactNativeStyle.js'); +var RelayPlugin = require('../plugins/Relay/RelayPlugin'); var consts = require('../agent/consts'); @@ -44,7 +46,7 @@ class Panel extends React.Component { _keyListener: ?(e: DOMEvent) => void; _checkTimeout: ?number; _unMounted: boolean; - _bridge: ?Bridge; + _bridge: Bridge; _store: Store; _unsub: ?() => void; @@ -55,6 +57,7 @@ class Panel extends React.Component { this.state = {loading: true, isReact: this.props.alreadyFoundReact}; this._unMounted = false; window.panel = this; + this.plugins = []; } getChildContext(): Object { @@ -141,6 +144,8 @@ class Panel extends React.Component { } teardown() { + this.plugins.forEach(p => p.teardown()); + this.plugins = []; if (this._keyListener) { window.removeEventListener('keydown', this._keyListener); this._keyListener = null; @@ -152,7 +157,6 @@ class Panel extends React.Component { this._teardownWall(); this._teardownWall = null; } - this._bridge = null; } inject() { @@ -161,13 +165,16 @@ class Panel extends React.Component { this._bridge = new Bridge(wall); - if (this._bridge) { - this._store = new Store(this._bridge); - } + this._store = new Store(this._bridge); + var refresh = () => this.forceUpdate(); + this.plugins = [ + new RelayPlugin(this._store, this._bridge, refresh), + ]; this._keyListener = keyboardNav(this._store, window); window.addEventListener('keydown', this._keyListener); + this._store.on('connected', () => { this.setState({loading: false}); this.getNewSelection(); @@ -211,6 +218,11 @@ class Panel extends React.Component { if (!this.state.isReact) { return

Looking for react...

; } + var extraTabs = assign.apply(null, [{}].concat(this.plugins.map(p => p.tabs()))); + var extraPanes = [].concat(...this.plugins.map(p => p.panes())); + if (this._store.capabilities.rnStyle) { + extraPanes.push(panelRNStyle(this._bridge)); + } return ( ); } diff --git a/frontend/SplitPane.js b/frontend/SplitPane.js index 0ff45bebf7..767e292e94 100644 --- a/frontend/SplitPane.js +++ b/frontend/SplitPane.js @@ -14,8 +14,16 @@ var React = require('react'); var Draggable = require('./Draggable'); var assign = require('object-assign'); +type Props = { + style: ?{[key: string]: any}, + left: () => ReactElement, + right: () => ReactElement, + initialWidth: number, +}; + class SplitPane extends React.Component { - constructor(props: Object) { + props: Props; + constructor(props: Props) { super(props); this.state = { width: props.initialWidth, @@ -59,7 +67,6 @@ class SplitPane extends React.Component { var styles = { container: { display: 'flex', - fontFamily: 'sans-serif', minWidth: 0, flex: 1, }, diff --git a/frontend/TabbedPane.js b/frontend/TabbedPane.js index a7e9e73692..a66ad1c11f 100644 --- a/frontend/TabbedPane.js +++ b/frontend/TabbedPane.js @@ -38,7 +38,7 @@ class TabbedPane extends React.Component { style = assign({}, style, styles.lastTab); } return ( -
  • this.props.setSelectedTab(name)}> +
  • this.props.setSelectedTab(name)}> {name}
  • ); @@ -90,6 +90,14 @@ var styles = { module.exports = decorate({ listeners: () => ['selectedTab'], + shouldUpdate: (props, prevProps) => { + for (var name in props) { + if (props[name] !== prevProps[name]) { + return true; + } + } + return false; + }, props(store) { return { selected: store.selectedTab, diff --git a/frontend/decorate.js b/frontend/decorate.js index 7e32161ea8..7f2a959786 100644 --- a/frontend/decorate.js +++ b/frontend/decorate.js @@ -51,6 +51,7 @@ type Options = { * }, MyComp); */ module.exports = function(options: Options, Component: any): any { + var storeKey = options.store || 'store'; class Wrapper extends React.Component { _listeners: Array; _update: () => void; @@ -61,25 +62,25 @@ module.exports = function(options: Options, Component: any): any { } componentWillMount() { - if (!this.context.store) { + if (!this.context[storeKey]) { return console.warn('no store on context...'); } this._update = () => this.forceUpdate(); if (!options.listeners) { return undefined; } - this._listeners = options.listeners(this.props, this.context.store); + this._listeners = options.listeners(this.props, this.context[storeKey]); this._listeners.forEach(evt => { - this.context.store.on(evt, this._update); + this.context[storeKey].on(evt, this._update); }); } componentWillUnmount() { - if (!this.context.store) { + if (!this.context[storeKey]) { return console.warn('no store on context...'); } this._listeners.forEach(evt => { - this.context.store.off(evt, this._update); + this.context[storeKey].off(evt, this._update); }); } @@ -94,32 +95,33 @@ module.exports = function(options: Options, Component: any): any { } componentWillUpdate(nextProps, nextState) { - if (!this.context.store) { + if (!this.context[storeKey]) { return console.warn('no store on context...'); } if (!options.listeners) { return undefined; } - var listeners = options.listeners(this.props, this.context.store); + var listeners = options.listeners(this.props, this.context[storeKey]); var diff = arrayDiff(listeners, this._listeners); diff.missing.forEach(name => { - this.context.store.off(name, this._update); + this.context[storeKey].off(name, this._update); }); diff.newItems.forEach(name => { - this.context.store.on(name, this._update); + this.context[storeKey].on(name, this._update); }); this._listeners = listeners; } render() { - var store = this.context.store; + var store = this.context[storeKey]; var props = store && options.props(store, this.props); return ; } } Wrapper.contextTypes = { - store: React.PropTypes.object, + // $FlowFixMe computed property + [storeKey]: React.PropTypes.object, }; Wrapper.displayName = 'Wrapper(' + Component.name + ')'; diff --git a/frontend/provideStore.js b/frontend/provideStore.js new file mode 100644 index 0000000000..88bfbbe245 --- /dev/null +++ b/frontend/provideStore.js @@ -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. + * + * @flow + */ +'use strict'; + +var React = require('react'); + +module.exports = function(name: string): Object { + class Wrapper extends React.Component { + props: { + children: () => ReactElement, + store: Object, + }; + getChildContext() { + // $FlowFixMe computed property + return {[name]: this.props.store}; + } + render() { + return this.props.children(); + } + } + Wrapper.childContextTypes = { + // $FlowFixMe computed property + [name]: React.PropTypes.object, + }; + Wrapper.displayName = 'StoreProvider(' + name + ')'; + return Wrapper; +}; + diff --git a/plugins/Relay/ElementPanel.js b/plugins/Relay/ElementPanel.js new file mode 100644 index 0000000000..76b5deb8a5 --- /dev/null +++ b/plugins/Relay/ElementPanel.js @@ -0,0 +1,106 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +var React = require('react'); +var decorate = require('../../frontend/decorate'); + +class ElementPanel { + props: { + dataIDs: Array<{id: string, queries: Array}>, + jumpToData: (id: string) => void, + jumpToQuery: (queryID: string) => void, + }; + + render() { + if (!this.props.dataIDs.length) { + return ; + } + return ( +
    + Relay Nodes +
      + {this.props.dataIDs.map(({id, queries}) => ( +
    • +
      this.props.jumpToData(id)}> + ID: {id} +
      +
        + {queries.map(query => ( +
      • this.props.jumpToQuery(query.get('id'))}> + {query.get('name')} +
      • + ))} + {!queries.length &&
      • No Queries
      • } +
      +
    • + ))} +
    +
    + ); + } +} + +var styles = { + dataNode: { + marginBottom: 5, + border: '1px solid #ccc', + }, + dataIDs: { + listStyle: 'none', + padding: 0, + margin: 0, + }, + queries: { + listStyle: 'none', + padding: 0, + margin: 0, + }, + dataID: { + cursor: 'pointer', + padding: '2px 4px', + backgroundColor: '#ccc', + }, + queryID: { + cursor: 'pointer', + padding: '2px 4px', + }, + noQueries: { + color: '#999', + padding: '2px 4px', + }, +}; + +module.exports = decorate({ + store: 'relayStore', + listeners(props, store) { + return [props.id]; + }, + shouldUpdate(props, prevProps) { + return props.id !== prevProps.id; + }, + props(store, props) { + var dataIDs = []; + if (store.nodesToDataIDs[props.id]) { + for (var id of store.nodesToDataIDs[props.id]) { + dataIDs.push({ + id, + queries: (store.queriesByDataID[id] || []).map(qid => store.queries.get(qid)), + }); + } + } + return { + dataIDs, + jumpToData: dataID => store.jumpToDataID(dataID), + jumpToQuery: queryID => store.jumpToQuery(queryID), + }; + }, +}, ElementPanel); diff --git a/plugins/Relay/QueriesTab.js b/plugins/Relay/QueriesTab.js new file mode 100644 index 0000000000..869d03d6f5 --- /dev/null +++ b/plugins/Relay/QueriesTab.js @@ -0,0 +1,62 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +var React = require('react'); +var decorate = require('../../frontend/decorate'); +var QueryList = require('./QueryList'); +var QueryViewer = require('./QueryViewer'); +var SplitPane = require('../../frontend/SplitPane'); + +class QueriesTab { + props: { + isSplit: boolean, + }; + render() { + var contents; + if (!this.props.isSplit) { + contents = ; + } else { + contents = ( + } + right={() => } + /> + ); + } + + return ( +
    + {contents} +
    + ); + } +} + +var styles = { + container: { + fontFamily: 'Menlo, sans-serif', + fontSize: 12, + flex: 1, + display: 'flex', + }, +}; + +module.exports = decorate({ + store: 'relayStore', + listeners: () => ['selectedQuery'], + props(store) { + return { + isSplit: !!store.selectedQuery, + }; + }, +}, QueriesTab); diff --git a/plugins/Relay/Query.js b/plugins/Relay/Query.js new file mode 100644 index 0000000000..590a90cf38 --- /dev/null +++ b/plugins/Relay/Query.js @@ -0,0 +1,101 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +import type {Map} from 'immutable'; + +var React = require('react'); +var assign = require('object-assign'); + +class Query { + props: { + data: Map, + onSelect: () => void, + }; + render(): ReactElement { + var data = this.props.data; + var containerStyle = styles.container; + if (this.props.isSelected) { + containerStyle = { + ...styles.container, + ...styles.selectedContainer, + }; + } + var statusStyle = assign({}, styles.status, { + backgroundColor: statusColors[data.get('status')] || statusColors.error, + }); + + return ( +
  • +
    +
    + {data.get('name')} +
    +
    + {new Date(data.get('start')).toLocaleTimeString()} +
    +
    + {data.get('end') - data.get('start')}ms +
    +
  • + ); + } +} + +var statusColors = { + pending: 'orange', + success: 'green', + failure: 'red', + error: '#aaa', +}; + +var styles = { + container: { + padding: '10px 20px', + cursor: 'pointer', + display: 'flex', + fontSize: 14, + }, + + selectedContainer: { + backgroundColor: '#eef', + }, + + name: { + flex: 1, + fontSize: 16, + padding: 10, + }, + + time: { + padding: 10, + }, + + duration: { + padding: 10, + }, + + status: { + width: 20, + height: 20, + margin: 10, + borderRadius: 25, + backgroundColor: '#aaa', + }, + + text: { + whiteSpace: 'pre', + fontFamily: 'monospace', + flex: 1, + }, +}; + +module.exports = Query; diff --git a/plugins/Relay/QueryList.js b/plugins/Relay/QueryList.js new file mode 100644 index 0000000000..762c19796a --- /dev/null +++ b/plugins/Relay/QueryList.js @@ -0,0 +1,71 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +import type {OrderedMap} from 'immutable'; + +var React = require('react'); +var decorate = require('../../frontend/decorate'); +var Query = require('./Query'); + +class QueryList { + props: { + queries: OrderedMap, + selectQuery: (id: string) => void, + selectedQuery: ?string, + }; + + render() { + return ( +
      + {this.props.queries.valueSeq().map(q => ( + // $FlowFixMe react element + this.props.selectQuery(q.get('id'))} + /> + )).toArray()} + {!this.props.queries.count() && +
    • No Relay Queries logged
    • } +
    + ); + } +} + +var styles = { + list: { + listStyle: 'none', + padding: 0, + margin: 0, + overflow: 'auto', + minHeight: 0, + flex: 1, + }, + + empty: { + padding: 50, + textAlign: 'center', + }, +}; + +module.exports = decorate({ + store: 'relayStore', + listeners: () => ['queries', 'selectedQuery'], + props(store, props) { + return { + queries: store.queries, + selectQuery: id => store.selectQuery(id), + selectedQuery: store.selectedQuery, + }; + }, +}, QueryList); diff --git a/plugins/Relay/QueryViewer.js b/plugins/Relay/QueryViewer.js new file mode 100644 index 0000000000..6881a66212 --- /dev/null +++ b/plugins/Relay/QueryViewer.js @@ -0,0 +1,120 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +import type {Map} from 'immutable'; + +var React = require('react'); +var decorate = require('../../frontend/decorate'); +var DataView = require('../../frontend/DataView/DataView'); + +class QueryViewer { + props: { + data: Map, + inspect: (path: Array, cb: () => void) => void, + }; + render(): ReactElement { + var data = this.props.data; + var info = { + }; + var type = data.get('type'); + if (type === 'mutation') { + info.mutation = data.get('mutation'); + } else { + info.query = data.get('query'); + } + info.variables = data.get('variables'); + var status = data.get('status'); + if (status === 'success') { + info.response = data.get('response'); + } else if (status === 'failure') { + info.error = data.get('error'); + } + return ( +
    +
    {data.get('name')}
    +
    + {new Date(data.get('start')).toLocaleTimeString()} +
    +
    + {data.get('end') - data.get('start')}ms +
    + +
    + ); + } +} + +var styles = { + container: { + padding: '10px 20px', + display: 'flex', + flexDirection: 'column', + overflow: 'auto', + minHeight: 0, + flex: 1, + }, + + title: { + fontSize: 20, + color: '#666', + marginBottom: 15, + }, + + name: { + }, + + time: { + padding: 10, + }, + + duration: { + padding: 10, + }, + + status: { + width: 20, + height: 20, + margin: 10, + borderRadius: 25, + backgroundColor: '#aaa', + }, + + variables: { + whiteSpace: 'pre', + fontFamily: 'monospace', + wordWrap: 'break-word', + }, + + text: { + whiteSpace: 'pre', + fontFamily: 'monospace', + wordWrap: 'break-word', + }, +}; + +module.exports = decorate({ + store: 'relayStore', + listeners: (props, store) => ['selectedQuery', store.selectedQuery], + props(store) { + return { + data: store.queries.get(store.selectedQuery), + inspect: store.inspect.bind(store, store.selectedQuery), + }; + }, +}, QueryViewer); diff --git a/plugins/Relay/RelayPlugin.js b/plugins/Relay/RelayPlugin.js new file mode 100644 index 0000000000..0b029689fa --- /dev/null +++ b/plugins/Relay/RelayPlugin.js @@ -0,0 +1,83 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +import type Bridge from '../../agent/Bridge'; +import type Store from '../../frontend/Store'; + +var React = require('react'); +var provideStore = require('../../frontend/provideStore'); + +var RelayStore = require('./Store'); +var QueriesTab = require('./QueriesTab'); +var StoreTab = require('./StoreTab'); +var ElementPanel = require('./ElementPanel'); + +var StoreWrapper = provideStore('relayStore'); + +class RelayPlugin { + hasRelay: bool; + bridge: Bridge; + store: Store; + relayStore: RelayStore; + + constructor(store: Store, bridge: Bridge, refresh: () => void) { + this.bridge = bridge; + this.store = store; + this.hasRelay = false; + this.relayStore = new RelayStore(bridge, store); + // TODO (kassens): There's a race condition here. The Relay backend + // implements this call and is initialized from the injected script whereas + // this file is called from the Panel. + setTimeout(() => { + bridge.call('relay:check', [], hasRelay => { + this.hasRelay = hasRelay; + refresh(); + }); + }, 1000); + } + + panes(): Array<(node: Object, id: string) => ReactElement> { + if (!this.hasRelay) { + return []; + } + return [ + (node, id) => ( + + {() => } + + ), + ]; + } + + teardown() { + } + + tabs(): ?{[key: string]: () => ReactElement} { + if (!this.hasRelay) { + return null; + } + return { + Relay: () => ( + + {() => } + + ), + RelayStore: () => ( + + {() => } + + ), + }; + } +} + +module.exports = RelayPlugin; diff --git a/plugins/Relay/Store.js b/plugins/Relay/Store.js new file mode 100644 index 0000000000..f24ed80ed4 --- /dev/null +++ b/plugins/Relay/Store.js @@ -0,0 +1,185 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +import type Bridge from '../../agent/Bridge'; + +var {EventEmitter} = require('events'); +var {OrderedMap, Map} = require('immutable'); +var assign = require('object-assign'); +var consts = require('../../agent/consts'); +var invariant = require('../../frontend/invariant'); + +function getDataIDs(obj, collector) { + for (var name in obj) { + if (name === 'id' && typeof obj[name] === 'string') { + collector.push(obj[name]); + } else if (typeof obj[name] === 'object') { + getDataIDs(obj[name], collector); + } + } +} + +class Store extends EventEmitter { + queries: OrderedMap; + storeData: ?{ + nodes: any, + }; + dataIDsToNodes: Map; + selectedDataNode: string; + nodesToDataIDs: Map; + _bridge: Bridge; + _mainStore: Object; + queriesByDataID: {[id: string]: Array}; + selectedQuery: ?string; + + constructor(bridge: Bridge, mainStore: Object) { + super(); + this.storeData = null; + this.selectedQuery = null; + this.queries = new OrderedMap(); + this._bridge = bridge; + this._mainStore = mainStore; + // initial population of the store + bridge.on('relay:store', data => { + this.storeData = data; + this.emit('storeData'); + }); + this.queriesByDataID = {}; + // queries and mutations + bridge.on('relay:pending', data => { + this.queries = this.queries.set(data.id, new Map(data).set('status', 'pending')); + this.emit('queries'); + this.emit(data.id); + var dataIDs = []; + getDataIDs(data.variables, dataIDs); + dataIDs.forEach(id => { + if (!this.queriesByDataID[id]) { + this.queriesByDataID[id] = [data.id]; + } else { + this.queriesByDataID[id].push(data.id); + } + }); + }); + bridge.on('relay:success', ({id, response, end}) => { + this.queries = this.queries.mergeIn([id], new Map({status: 'success', response, end})); + this.emit('queries'); + this.emit(id); + }); + bridge.on('relay:failure', ({id, error, end}) => { + this.queries = this.queries.mergeIn([id], new Map({status: 'failure', error, end})); + this.emit('queries'); + this.emit(id); + }); + this.dataIDsToNodes = {}; + this.nodesToDataIDs = {}; + // track nodes + bridge.on('mount', data => { + if (!data.props || (!data.props.relay && data.name.indexOf('Relay(') !== 0)) { + return; // not a relay child + } + this.nodesToDataIDs[data.id] = new window.Set(); + for (var name in data.props) { + var id = data.props[name].__dataID__; + if (!id) { + continue; + } + if (!this.dataIDsToNodes[id]) { + this.dataIDsToNodes[id] = new window.Set(); + } + this.dataIDsToNodes[id].add(data.id); + this.nodesToDataIDs[data.id].add(id); + } + }); + bridge.on('update', data => { + if (!data.props || !this.nodesToDataIDs[data.id]) { + return; + } + var newIds = new window.Set(); + for (var name in data.props) { + var id = data.props[name].__dataID__; + if (!id) { + continue; + } + newIds.add(id); + if (this.nodesToDataIDs[data.id].has(id)) { + continue; + } + if (!this.dataIDsToNodes[id]) { + this.dataIDsToNodes[id] = new window.Set(); + } + this.dataIDsToNodes[id].add(data.id); + // this.nodesToDataIDs[data.id].add(id); + } + + for (var item of this.nodesToDataIDs[data.id]) { + if (!newIds.has(item)) { + this.dataIDsToNodes[item].delete(data.id); + } + } + this.nodesToDataIDs[id] = newIds; + }); + bridge.on('unmount', id => { + if (!this.nodesToDataIDs[id]) { + return; + } + for (var item of this.nodesToDataIDs[id]) { + this.dataIDsToNodes[item].delete(id); + } + this.nodesToDataIDs[id] = null; + }); + } + + jumpToDataID(dataID: string) { + this._mainStore.setSelectedTab('RelayStore'); + this.selectedDataNode = dataID; + this.emit('selectedDataNode'); + } + + jumpToQuery(queryID: string) { + this._mainStore.setSelectedTab('Relay'); + this.selectedQuery = queryID; + this.emit('selectedQuery'); + this.emit('queries'); + } + + inspect(id: string, path: Array, cb: () => void) { + this._bridge.inspect(id, path, value => { + var base; + if (id === 'relay:store') { + invariant( + this.storeData, + 'RelayStore.inspect: this.storeData should be defined.' + ); + base = this.storeData.nodes; + } else { + base = this.queries.get(id).get(path[0]); + } + var inspected = path.slice(1).reduce((obj, attr) => obj ? obj[attr] : null, base); + if (inspected) { + assign(inspected, value); + inspected[consts.inspected] = true; + } + cb(); + }); + } + + off(evt: string, fn: () => void) { + this.removeListener(evt, fn); + } + + selectQuery(id: string) { + this.selectedQuery = id; + this.emit('selectedQuery'); + } +} + +module.exports = Store; diff --git a/plugins/Relay/StoreTab.js b/plugins/Relay/StoreTab.js new file mode 100644 index 0000000000..7bb4016e8f --- /dev/null +++ b/plugins/Relay/StoreTab.js @@ -0,0 +1,73 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +import type {Map} from 'immutable'; + +var React = require('react'); +var DataView = require('../../frontend/DataView/DataView'); +var decorate = require('../../frontend/decorate'); + +class StoreTab { + props: { + data: Map, + inspect: (path: Array, cb: () => void) => void, + }; + render(): ReactElement { + if (!this.props.storeData) { + return ( +
    +

    Loading...

    +
    + ); + } + return ( +
    +

    Default Store

    + +
    + ); + } +} + +var styles = { + container: { + fontFamily: 'Menlo, sans-serif', + minHeight: 0, + flex: 1, + overflow: 'auto', + fontSize: 12, + padding: 30, + }, + loading: { + textAlign: 'center', + color: '#aaa', + }, +}; + +module.exports = decorate({ + store: 'relayStore', + listeners: () => ['storeData'], + props(store) { + return { + storeData: store.storeData, + inspect: store.inspect.bind(store, 'relay:store'), + }; + }, +}, StoreTab); diff --git a/plugins/Relay/backend.js b/plugins/Relay/backend.js new file mode 100644 index 0000000000..563889d895 --- /dev/null +++ b/plugins/Relay/backend.js @@ -0,0 +1,84 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +import type Bridge from '../../agent/Bridge'; +import type Agent from '../../agent/Agent'; + +function decorate(obj, attr, fn) { + var old = obj[attr]; + obj[attr] = function() { + var res = old.apply(this, arguments); + fn.apply(this, arguments); + return res; + }; + return () => (obj[attr] = old); +} + +function makeId() { + return Math.random().toString(16); +} + +module.exports = (bridge: Bridge, agent: Agent, hook: Object) => { + var shouldEnable = !!( + hook._relayInternals && + window.location.hash.indexOf('relaydevtools') >= 0 + ); + + bridge.onCall('relay:check', () => shouldEnable); + if (!shouldEnable) { + return; + } + var NetworkLayer = hook._relayInternals.NetworkLayer; + + bridge.send('relay:store', {id: 'relay:store', nodes: hook._relayInternals.DefaultStoreData.getNodeData()}); + var restore = [ + decorate(NetworkLayer, 'sendMutation', mut => { + var id = makeId(); + var start = Date.now(); + bridge.send('relay:pending', { + id, + type: 'mutation', + start, + mutation: mut._mutation, + text: mut.getQueryString(), + variables: mut.getVariables(), + name: mut.getDebugName(), + }); + mut.then( + response => bridge.send('relay:success', {id, response: response.response, end: Date.now()}), + error => bridge.send('relay:failure', {id, error, end: Date.now()}) + ); + }), + + decorate(NetworkLayer, 'sendQueries', queries => { + bridge.send('relay:pending', queries.map(q => { + var id = makeId(); + q.then( + response => bridge.send('relay:success', {id, response: response.response, end: Date.now()}), + error => bridge.send('relay:failure', {id, error, end: Date.now()}) + ); + return { + id, + type: 'query', + start: Date.now(), + query: q._query, + text: q.getQueryString(), + variables: q.getVariables(), + name: q.getDebugName(), + }; + })); + }), + ]; + hook.on('shutdown', () => { + restore.forEach(fn => fn()); + }); +}; diff --git a/shells/chrome/src/backend.js b/shells/chrome/src/backend.js index 6d2e146a4b..ede27b3d99 100644 --- a/shells/chrome/src/backend.js +++ b/shells/chrome/src/backend.js @@ -14,6 +14,7 @@ var Agent = require('../../../agent/Agent'); var Bridge = require('../../../agent/Bridge'); var setupHighlighter = require('../../../frontend/Highlighter/setup'); var setupRNStyle = require('../../../plugins/ReactNativeStyle/setupBackend'); +var setupRelay = require('../../../plugins/Relay/backend'); var inject = require('../../../agent/inject'); @@ -70,6 +71,8 @@ function setup(hook) { setupRNStyle(bridge, agent, hook.resolveRNStyle); } + setupRelay(bridge, agent, hook); + agent.on('shutdown', () => { hook.emit('shutdown'); listeners.forEach(fn => { diff --git a/shells/plain/backend.js b/shells/plain/backend.js index ccb2742ac4..438539cf28 100644 --- a/shells/plain/backend.js +++ b/shells/plain/backend.js @@ -13,6 +13,7 @@ var Agent = require('../../agent/Agent'); var Bridge = require('../../agent/Bridge'); var setupHighlighter = require('../../frontend/Highlighter/setup'); +var setupRelay = require('../../plugins/Relay/backend'); var inject = require('../../agent/inject'); @@ -32,3 +33,4 @@ agent.addBridge(bridge); inject(window.__REACT_DEVTOOLS_GLOBAL_HOOK__, agent); setupHighlighter(agent); +setupRelay(bridge, agent, window.__REACT_DEVTOOLS_GLOBAL_HOOK__); diff --git a/shells/plain/container.js b/shells/plain/container.js index 1d23e65b89..bf14fa6bab 100644 --- a/shells/plain/container.js +++ b/shells/plain/container.js @@ -18,20 +18,22 @@ window.React = React; var Panel = require('../../frontend/Panel'); var target: Object = document.getElementById('target'); -function inject(src, done) { - var script = target.contentDocument.createElement('script'); - script.src = src; - script.onload = done; - target.contentDocument.body.appendChild(script); -} + +var appSrc = target.getAttribute('data-app-src') || '../../test/example/build/target.js'; +var devtoolsSrc = target.getAttribute('data-devtools-src') || './build/backend.js'; var win = target.contentWindow; globalHook(win); +var iframeSrc = document.getElementById('iframe-src'); +if (iframeSrc) { + win.document.documentElement.innerHTML = iframeSrc.textContent.replace(/>/g, '>'); +} + var config = { alreadyFoundReact: true, inject(done) { - inject('./build/backend.js', () => { + inject(devtoolsSrc, () => { var wall = { listen(fn) { win.parent.addEventListener('message', evt => fn(evt.data)); @@ -45,7 +47,26 @@ var config = { }, }; -inject('../../test/example/build/target.js', () => { +function inject(src, done) { + if (!src || src === 'false') { + return done(); + } + var script = target.contentDocument.createElement('script'); + script.src = src; + script.onload = done; + target.contentDocument.body.appendChild(script); +} + +function injectMany(sources, done) { + if (sources.length === 1) { + return inject(sources[0], done); + } + inject(sources[0], () => injectMany(sources.slice(1), done)); +} + +var sources = appSrc.split('|'); + +injectMany(sources, () => { var node = document.getElementById('container'); React.render(, node); }); diff --git a/shells/plain/global-hook.js b/shells/plain/global-hook.js new file mode 100644 index 0000000000..5a3b9d631c --- /dev/null +++ b/shells/plain/global-hook.js @@ -0,0 +1,15 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +var globalHook = require('../../backend/GlobalHook'); +globalHook(window); + diff --git a/shells/plain/webpack.config.js b/shells/plain/webpack.config.js index aa98186039..1167093cb0 100644 --- a/shells/plain/webpack.config.js +++ b/shells/plain/webpack.config.js @@ -14,6 +14,7 @@ module.exports = { entry: { backend: './backend.js', container: './container.js', + globalHook: './global-hook.js', }, output: { path: __dirname + '/build',