├── .eslintignore ├── .flowconfig ├── .gitignore ├── .gitmodules ├── .travis.yml ├── .yarnrc ├── Dockerfile ├── LICENSE ├── README.md ├── __tests__ ├── DevTools.spec.js ├── Global.spec.js ├── NetworkDisplayer.spec.js ├── Record.spec.js ├── StoreDisplayer.spec.js ├── StoreTimeline.spec.js ├── __mocks__ │ └── styleMock.js ├── __snapshots__ │ ├── DevTools.spec.js.snap │ ├── NetworkDisplayer.spec.js.snap │ ├── StoreDisplayer.spec.js.snap │ └── StoreTimeline.spec.js.snap ├── bridge.spec.js ├── global-setup.js └── store.spec.js ├── assets ├── network.gif ├── protologo.png ├── protorelaystore.gif ├── protostar-mutations.gif ├── protostar-records-filter.gif ├── searchbar.gif └── snapshot.gif ├── babel.config.js ├── docker-compose.yml ├── flow.js ├── package.json ├── shells ├── browser │ ├── chrome │ │ ├── build.js │ │ ├── manifest.json │ │ ├── nottest.js │ │ ├── now.json │ │ └── watch.js │ └── shared │ │ ├── assets │ │ ├── proto128.png │ │ ├── proto16.png │ │ ├── proto32.png │ │ ├── proto48.png │ │ └── protorelay.png │ │ ├── build.js │ │ ├── index.html │ │ ├── main.html │ │ ├── src │ │ ├── backend.js │ │ ├── background.js │ │ ├── contentScript.js │ │ ├── inject.js │ │ ├── injectGlobalHook.js │ │ ├── main.js │ │ └── utils.js │ │ ├── view │ │ ├── App.jsx │ │ ├── index.js │ │ └── styles.scss │ │ ├── webpack.backend.js │ │ └── webpack.config.js └── utils.js ├── src ├── backend │ ├── EnvironmentWrapper.js │ ├── agent.js │ ├── index.js │ ├── types.js │ └── utils.js ├── bridge.js ├── devtools │ ├── DevTools.js │ ├── context.js │ ├── store.js │ ├── utils.js │ └── view │ │ ├── Components │ │ ├── EnvironmentSelector.js │ │ ├── Record.js │ │ └── SnapshotLinks.js │ │ ├── NetworkDisplayer.js │ │ ├── StoreDisplayer.js │ │ └── StoreTimeline.js ├── hook.js └── types.js └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | shells/browser/chrome/build 4 | shells/browser/firefox/build 5 | shells/browser/shared/build 6 | shells/dev/dist 7 | vendor 8 | *.js.snap 9 | 10 | package-lock.json 11 | yarn.lock 12 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | shells/browser/chrome/build/* 3 | shells/browser/firefox/build/* 4 | shells/dev/build/* 5 | 6 | [declarations] 7 | /node_modules/graphql 8 | 9 | [include] 10 | 11 | [libs] 12 | /flow-typed/ 13 | ./flow.js 14 | 15 | [lints] 16 | 17 | [options] 18 | esproposal.class_instance_fields=enable 19 | suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe 20 | suppress_comment=\\(.\\|\n\\)*\\$FlowIssue 21 | suppress_comment=\\(.\\|\n\\)*\\$FlowIgnore 22 | module.name_mapper='^src' ->'/src' 23 | esproposal.optional_chaining=enable 24 | 25 | [strict] 26 | 27 | [version] 28 | ^0.113.0 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /shells/browser/chrome/*.crx 2 | /shells/browser/chrome/*.pem 3 | /shells/browser/firefox/*.xpi 4 | /shells/browser/firefox/*.pem 5 | /shells/browser/shared/build 6 | /packages/relay-devtools-core/dist 7 | /shells/dev/dist 8 | build 9 | node_modules 10 | npm-debug.log 11 | yarn-error.log 12 | .DS_Store 13 | yarn-error.log 14 | .vscode 15 | .idea 16 | *.pem 17 | dist 18 | package-lock.json -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "relay-examples"] 2 | path = relay-examples 3 | url = https://github.com/relayjs/relay-examples.git 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | services: 2 | - docker 3 | dist: xenial 4 | script: 5 | - docker-compose up --abort-on-container-exit -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | yarn-offline-mirror false 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12.18.3 2 | WORKDIR /usr/src/app 3 | COPY . /usr/src/app 4 | RUN npm install 5 | CMD npm run test 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Facebook, Inc. and its affiliates. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
5 | 6 |

Proto Relay

7 | Proto Relay is a Chrome extension devtool for React Relay based off the official devtool. It is designed to be light-weight, performant, and easy-to-use. 8 | 9 | ## Features 10 | - [x] Preview Relay store content from the Chrome devtools panel 11 | - [x] View store content over time with included snapshots 12 | - [x] View store mutations and network queries 13 | 14 | ## Installation 15 | 1. Fork and clone this repository onto your local computer 16 | 2. Install dependencies and run a build using either the 'Yarn' or 'NPM' commands below: 17 | ```node 18 | # Yarn 19 | yarn install 20 | yarn build 21 | 22 | # NPM 23 | npm run install 24 | npm run build 25 | ``` 26 | 3. Access the Chome extensions within the browser 27 | 4. Access [Chrome extensions](chrome://extensions/) within the browser 28 | 5. Click on "Load Unpacked" 29 | 6. Navigate and select the folder: protostar-relay > Shells > browser > chrome > build > unpacked 30 | 7. Go to a website built with Relay and open the "proto*" panel. Websites that use Relay include: 31 | - [facebook.com](https://www.facebook.com/) 32 | - [artsy.com](https://www.artsy.net/) 33 | - [oculus.com](https://www.oculus.com/) 34 | 35 | 36 | ## How to Use 37 | 38 | - Example view of interacting with the Relay store. 39 | 40 | 41 | - Example of snapshot functionality and viewing mutations. 42 | 43 | ## Contributing 44 | Protostar-relay is currently in beta release. We encourage you to submit issues for any bugs or ideas for enhancements. Also feel free to fork this repo and submit pull requests to contribute as well. Below are some features we would like to add as we iterate on this project: 45 | - Optimistic updates: 46 | - Visual representation. 47 | - List of all optimistic updates with pending/resolved status. 48 | - Control data flow. 49 | 50 | ## Google Chrome Web Store 51 | Get it on the Chrome Extension Store: [coming soon](). 52 | 53 | ## Contributors 54 | [Aryeh Kobrinsky](https://github.com/akobrinsky), 55 | [Liz Lotto](https://github.com/elizlotto), 56 | [Marc Burnie](https://github.com/marcburnie), 57 | [Qwen Ballard](https://github.com/qwenballard) 58 | 59 | 60 | ## License 61 | This project is licensed under the MIT License- see the [LICENSE.md](https://github.com/oslabs-beta/protostar-relay/blob/master/LICENSE) for more details. 62 | 63 | * Inspired by [Facebook's Relay Devtool](https://github.com/relayjs/relay-devtools) 64 | -------------------------------------------------------------------------------- /__tests__/DevTools.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { configure, shallow, render } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | import renderer from 'react-test-renderer'; 5 | import DevTools from '../src/devtools/DevTools'; 6 | 7 | configure({ adapter: new Adapter() }); 8 | 9 | describe('DevTools', () => { 10 | let wrapper; 11 | const RealDate = Date; 12 | const names = { 1: 'first', 2: 'second', 3: 'third' }; 13 | const props = { 14 | store: { 15 | getEnvironmentIDs: () => [1, 2, 3], 16 | getEnvironmentName: id => names[id] 17 | }, 18 | bridge: 'hi' 19 | }; 20 | 21 | beforeAll(() => { 22 | wrapper = shallow(); 23 | }); 24 | 25 | it('Passes the bridge to provider', () => { 26 | expect(wrapper.prop('value')).toEqual(props.bridge); 27 | }); 28 | 29 | it('Passes the store to provider', () => { 30 | expect( 31 | wrapper 32 | .children() 33 | .first() 34 | .prop('value') 35 | ).toEqual(props.store); 36 | }); 37 | 38 | it('Has a dropdown select element for environmentID with an onChange method', () => { 39 | expect(wrapper.find('select').length).toEqual(1); 40 | expect(wrapper.find('select').prop('onChange')).not.toEqual(undefined); 41 | }); 42 | 43 | it('Lists environments in dropdown selector', () => { 44 | const option = wrapper.find('option'); 45 | expect(option.length).toEqual(3); 46 | expect(option.at(0).text()).toEqual(names[1]); 47 | expect(option.at(1).text()).toEqual(names[2]); 48 | expect(option.at(2).text()).toEqual(names[3]); 49 | expect(option.at(0).prop('value')).toEqual(1); 50 | expect(option.at(1).prop('value')).toEqual(2); 51 | expect(option.at(2).prop('value')).toEqual(3); 52 | }); 53 | 54 | it('Has a StoreTimeline component', () => { 55 | expect(wrapper.find('StoreTimeline').length).toEqual(1); 56 | }); 57 | 58 | it('Has a NetworkDisplayer component', () => { 59 | expect(wrapper.find('NetworkDisplayer').length).toEqual(1); 60 | }); 61 | 62 | it('Passes the current environment to a currentEnvID prop on StoreTimeline and defaults to the first ID', () => { 63 | expect(wrapper.find('StoreTimeline').prop('currentEnvID')).toEqual(1); 64 | }); 65 | 66 | it('Can select between different environments and pass the current environment to a currentEnvID prop on StoreTimeline', () => { 67 | wrapper.find('select').simulate('change', { target: { value: 2 } }); 68 | expect(wrapper.find('StoreTimeline').prop('currentEnvID')).toEqual(2); 69 | wrapper.find('select').simulate('change', { target: { value: 3 } }); 70 | expect(wrapper.find('StoreTimeline').prop('currentEnvID')).toEqual(3); 71 | wrapper.find('select').simulate('change', { target: { value: 1 } }); 72 | expect(wrapper.find('StoreTimeline').prop('currentEnvID')).toEqual(1); 73 | }); 74 | 75 | it('Has network hidden by default and store is visible', () => { 76 | expect( 77 | wrapper 78 | .find('StoreTimeline') 79 | .parent() 80 | .hasClass('is-hidden') 81 | ).toEqual(false); 82 | expect( 83 | wrapper 84 | .find('NetworkDisplayer') 85 | .parent() 86 | .hasClass('is-hidden') 87 | ).toEqual(true); 88 | }); 89 | 90 | it('Allows user to select between store and network view', () => { 91 | const networkSelector = wrapper.find('#networkSelector'); 92 | expect(networkSelector.length).toEqual(1); 93 | expect(networkSelector.prop('onClick')).not.toEqual(undefined); 94 | networkSelector.simulate('click'); 95 | expect( 96 | wrapper 97 | .find('StoreTimeline') 98 | .parent() 99 | .hasClass('is-hidden') 100 | ).toEqual(true); 101 | expect( 102 | wrapper 103 | .find('NetworkDisplayer') 104 | .parent() 105 | .hasClass('is-hidden') 106 | ).toEqual(false); 107 | 108 | const storeSelector = wrapper.find('#storeSelector'); 109 | expect(storeSelector.length).toEqual(1); 110 | expect(storeSelector.prop('onClick')).not.toEqual(undefined); 111 | storeSelector.simulate('click'); 112 | expect( 113 | wrapper 114 | .find('StoreTimeline') 115 | .parent() 116 | .hasClass('is-hidden') 117 | ).toEqual(false); 118 | expect( 119 | wrapper 120 | .find('NetworkDisplayer') 121 | .parent() 122 | .hasClass('is-hidden') 123 | ).toEqual(true); 124 | }); 125 | 126 | it('Renders correctly', () => { 127 | const date = new Date(Date.UTC(2020)); 128 | global.Date = jest.fn(() => date); 129 | const tree = renderer.create().toJSON(); 130 | expect(tree).toMatchSnapshot(); 131 | jest.clearAllMocks(); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /__tests__/Global.spec.js: -------------------------------------------------------------------------------- 1 | describe('Timezones', () => { 2 | it('should always be UTC', () => { 3 | expect(new Date().getTimezoneOffset()).toBe(0); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /__tests__/NetworkDisplayer.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { configure, shallow, render } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | import renderer from 'react-test-renderer'; 5 | import NetworkDisplayer from '../src/devtools/view/NetworkDisplayer'; 6 | 7 | configure({ adapter: new Adapter() }); 8 | 9 | describe('NetworkDisplayer', () => { 10 | let wrapper; 11 | const props = {}; 12 | 13 | beforeAll(() => { 14 | wrapper = shallow(); 15 | }); 16 | 17 | it('My Test Case', () => { 18 | expect(true).toEqual(true); 19 | }); 20 | 21 | it('My Test Case', () => { 22 | expect(wrapper.find('Record').length).toEqual(0); 23 | }); 24 | 25 | it('Renders correctly', () => { 26 | const tree = renderer.create().toJSON(); 27 | expect(tree).toMatchSnapshot(); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /__tests__/Record.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { configure, shallow, render } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | // import toJson from 'enzyme-to-json'; 5 | 6 | import renderer from 'react-test-renderer'; 7 | 8 | import Record from '../src/devtools/view/Components/Record'; 9 | 10 | configure({ adapter: new Adapter() }); 11 | 12 | describe('Record', () => { 13 | let wrapper; 14 | let children; //alternative to .next, not always required. 15 | const props = { 16 | //hardcode in what to pass into component 17 | hi: true, 18 | nested: { this: 'that' } 19 | }; 20 | 21 | beforeAll(() => { 22 | wrapper = shallow(); 23 | children = wrapper.children(); 24 | }); 25 | 26 | it('Renders a
tag with a className of "records"', () => { 27 | //we are using methods from enzyme library so look at the docs 28 | expect(wrapper.type()).toEqual('div'); 29 | expect(wrapper.hasClass('records')).toEqual(true); 30 | }); 31 | 32 | it('Has two children divs: one with className objectProperty and the other with className nestedObject', () => { 33 | expect(children.length).toEqual(2); 34 | expect(children.first().hasClass('objectProperty')).toEqual(true); 35 | expect(children.last().hasClass('nestedObject')).toEqual(true); 36 | }); 37 | 38 | it('Has a object property child with two spans, first has class of key and second has class of value. And has text values for the key and stringifies boolean values.', () => { 39 | const rootChildren = children.first().children(); 40 | expect(rootChildren.length).toEqual(2); 41 | expect(rootChildren.first().hasClass('key')).toEqual(true); 42 | expect(rootChildren.first().text()).toEqual('hi: '); 43 | expect(rootChildren.last().hasClass('value')).toEqual(true); 44 | expect(rootChildren.last().text()).toEqual('true'); 45 | }); 46 | 47 | it('has a nested object child that has a span with class of key and a div with class of records. It also has a first child that has text of "nested: "', () => { 48 | const rootChildren = children.last().children(); 49 | expect(rootChildren.first().text()).toEqual('nested: '); 50 | expect(rootChildren.length).toEqual(2); 51 | expect(rootChildren.first().hasClass('key')).toEqual(true); 52 | expect(rootChildren.last().find(Record).length).toEqual(1); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /__tests__/StoreDisplayer.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { configure, shallow, render } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | import renderer from 'react-test-renderer'; 5 | import StoreDisplayer from '../src/devtools/view/StoreDisplayer'; 6 | 7 | configure({ adapter: new Adapter() }); 8 | 9 | describe('StoreDisplayer', () => { 10 | 11 | let wrapper; 12 | let useEffect; 13 | const store = { 14 | '1': { 15 | '__id': '1', 16 | '__typename': 'User', 17 | 'name': 'Marc' 18 | }, 19 | '2': { 20 | '__id': '2', 21 | '__typename': 'User', 22 | 'name': 'Aryeh' 23 | }, 24 | '3': { 25 | '__id': '3', 26 | '__typename': 'User', 27 | 'name': 'Liz' 28 | }, 29 | '4': { 30 | '__id': '4', 31 | '__typename': 'User', 32 | 'name': 'Qwen' 33 | }, 34 | '5': { 35 | '__id': '5', 36 | '__typename': 'Post', 37 | 'text': 'Hi' 38 | } 39 | } 40 | 41 | const mockUseEffect = () => { 42 | useEffect.mockImplementationOnce(f => f()); 43 | }; 44 | 45 | 46 | beforeEach(() => { 47 | useEffect = jest.spyOn(React, "useEffect"); 48 | mockUseEffect(); 49 | wrapper = shallow(); 50 | }); 51 | 52 | it("Has a Record component with the filtered records passed as props", () => { 53 | expect(wrapper.find('Record').length).toEqual(1); 54 | expect(wrapper.find('Record').props()).toEqual(store); 55 | }) 56 | 57 | it("Has a menu", () => { 58 | expect(wrapper.find('.menu').length).toEqual(1); 59 | }) 60 | 61 | describe("Menu", () => { 62 | let menu; 63 | 64 | beforeEach(() => { 65 | menu = wrapper.find('.menu'); 66 | }) 67 | 68 | it("Generates a list of menu items from the store object", () => { 69 | expect(menu.find("#type-User").length).toEqual(1); 70 | expect(menu.find("#type-Post").length).toEqual(1); 71 | Object.keys(store).forEach(k => { 72 | expect(menu.find(`#id-${k}`).length).toEqual(1); 73 | }) 74 | }) 75 | 76 | it("Has menu items with an onClick event that filters the results displayed on the screen based on ID", () => { 77 | Object.keys(store).forEach(k => { 78 | menu.find(`#id-${k}`).simulate('click'); 79 | expect(wrapper.find("Record").props()).toEqual({ [k]: store[k] }) 80 | }) 81 | }) 82 | 83 | it("Has menu items with an onClick event that filters the results displayed on the screen based on type", () => { 84 | menu.find(`#type-User`).simulate('click'); 85 | expect(wrapper.find("Record").props()).toEqual({ 86 | '1': { 87 | '__id': '1', 88 | '__typename': 'User', 89 | 'name': 'Marc' 90 | }, 91 | '2': { 92 | '__id': '2', 93 | '__typename': 'User', 94 | 'name': 'Aryeh' 95 | }, 96 | '3': { 97 | '__id': '3', 98 | '__typename': 'User', 99 | 'name': 'Liz' 100 | }, 101 | '4': { 102 | '__id': '4', 103 | '__typename': 'User', 104 | 'name': 'Qwen' 105 | } 106 | }) 107 | }) 108 | 109 | it("Adds an 'is-active' class to the currently selected menu item", () => { 110 | Object.keys(store).forEach(k => { 111 | let menuItem = wrapper.find(`#id-${k}`) 112 | expect(menuItem.length).toEqual(1) 113 | menuItem.props().onClick(); 114 | menuItem = wrapper.find(`#id-${k}`) 115 | expect(menuItem.hasClass('is-active')).toEqual(true) 116 | expect(menuItem.hasClass('is-active')).toEqual(true) 117 | menu.find("a").forEach(el => { 118 | if (el !== menuItem) expect(el.hasClass('is-active')).toEqual(false) 119 | }) 120 | }) 121 | 122 | let menuItem = wrapper.find(`#type-User`) 123 | expect(menuItem.length).toEqual(1) 124 | menuItem.simulate('click'); 125 | menuItem = wrapper.find(`#type-User`) 126 | expect(menuItem.hasClass('is-active')).toEqual(true) 127 | menu.find("a").forEach(el => { 128 | if (el !== menuItem) expect(el.hasClass('is-active')).toEqual(false) 129 | }) 130 | menuItem = wrapper.find(`#type-Post`) 131 | expect(menuItem.length).toEqual(1) 132 | menuItem.simulate('click'); 133 | menuItem = wrapper.find(`#type-Post`) 134 | expect(menuItem.hasClass('is-active')).toEqual(true) 135 | menu.find("a").forEach(el => { 136 | if (el !== menuItem) expect(el.hasClass('is-active')).toEqual(false) 137 | }) 138 | }) 139 | 140 | it("Removes the 'is-active' class when the reset button is clicked", () => { 141 | let menuItem = menu.find(`#type-User`) 142 | expect(menuItem.length).toEqual(1) 143 | menuItem.simulate('click'); 144 | expect(wrapper.find('.menu').find(".is-active").length).toEqual(1); 145 | wrapper.find('button').simulate('click'); 146 | expect(wrapper.find('.menu').find(".is-active").length).toEqual(0); 147 | }) 148 | 149 | it('Has a search input with an onChange property', () => { 150 | expect(wrapper.find('input').length).toEqual(1); 151 | expect(wrapper.find('input').prop('onChange')).not.toBe(undefined); 152 | }); 153 | 154 | describe('Search Box', () => { 155 | let search; 156 | 157 | beforeEach(() => { 158 | search = wrapper.find('input'); 159 | }) 160 | 161 | it("Filters the menu items", () => { 162 | search.prop('onChange')({ target: { value: 'Marc' } }); 163 | jest.runAllTimers(); 164 | expect(wrapper.find(`#id-1`).length).toEqual(1); 165 | Object.keys(store).forEach(k => { 166 | if (k !== '1') expect(wrapper.find(`#id-${k}`).length).toEqual(0); 167 | }) 168 | }) 169 | 170 | it("Debounces the input", () => { 171 | search.prop('onChange')({ target: { value: 'Marc' } }); 172 | Object.keys(store).forEach(k => { 173 | if (k !== '1') expect(wrapper.find(`#id-${k}`).length).toEqual(1); 174 | }) 175 | jest.runAllTimers(); 176 | Object.keys(store).forEach(k => { 177 | if (k !== '1') expect(wrapper.find(`#id-${k}`).length).toEqual(0); 178 | }) 179 | }) 180 | }); 181 | 182 | it('Has a Reset Button with an onClick property', () => { 183 | expect(wrapper.find('button').length).toEqual(1); 184 | expect(wrapper.find('button').prop('onClick')).not.toBe(undefined); 185 | }); 186 | 187 | describe('Reset Button', () => { 188 | it("Has a reset button that removes any selectors", () => { 189 | menu.find(`#type-User`).simulate('click'); 190 | expect(wrapper.find('Record').props()).not.toEqual(store) 191 | wrapper.find('button').simulate('click'); 192 | expect(wrapper.find('Record').props()).toEqual(store) 193 | }) 194 | }); 195 | 196 | }) 197 | 198 | it('Renders correctly', () => { 199 | const tree = renderer.create().toJSON(); 200 | expect(tree).toMatchSnapshot(); 201 | }) 202 | 203 | }); -------------------------------------------------------------------------------- /__tests__/StoreTimeline.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { configure, shallow, render } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | import renderer from 'react-test-renderer'; 5 | import StoreTimeline from '../src/devtools/view/StoreTimeline'; 6 | 7 | configure({ adapter: new Adapter() }); 8 | 9 | describe('StoreTimeline', () => { 10 | let wrapper; 11 | const props = { 12 | currentEnvID: 1 13 | }; 14 | 15 | beforeEach(() => { 16 | wrapper = shallow(); 17 | }); 18 | 19 | it('Renders a StoreDisplayer component and passes store as a prop', () => { 20 | expect(wrapper.find('StoreDisplayer').length).toEqual(1); 21 | }); 22 | 23 | // it("Passes the store based on the currentEnvID", () => { 24 | // }) 25 | 26 | // describe("Snapshots", () => { 27 | 28 | // it("Takes a snapshot at startup", () => { 29 | 30 | // }) 31 | 32 | // it("Has a snapshot button that takes and saves a snapshot", () => { 33 | 34 | // }) 35 | 36 | // it("Defaults to displaying the latest store value when a snapshot is taken", () => { 37 | 38 | // }) 39 | 40 | // it("Remembers snapshots when switching between environments", () => { 41 | 42 | // }) 43 | 44 | // it("Has a snapshot text input", () => { 45 | 46 | // }) 47 | 48 | // it("Has a previous buttons to move to the previous snapshot", () => { 49 | 50 | // }) 51 | 52 | // it("Has a next button to move to the previous snapshot", () => { 53 | 54 | // }) 55 | 56 | // it("Has a current button that shows the current store value", () => { 57 | 58 | // }) 59 | 60 | // it("Has a slider that updates when a new snapshot is taken and when switching between environments", () => { 61 | 62 | // }) 63 | 64 | // }) 65 | 66 | it('Renders correctly', () => { 67 | const date = new Date(Date.UTC(2020)); 68 | global.Date = jest.fn(() => date); 69 | const tree = renderer.create().toJSON(); 70 | expect(tree).toMatchSnapshot(); 71 | jest.clearAllMocks(); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /__tests__/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; -------------------------------------------------------------------------------- /__tests__/__snapshots__/DevTools.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`DevTools Renders correctly 1`] = ` 4 | Array [ 5 |
8 |
11 | 31 |
32 | 76 |
79 | 83 | 86 | 87 |
88 |
, 89 |
92 |
95 |
98 |
101 | 108 | 114 |
115 |
116 |
119 |
123 |
131 | 134 | 137 | 0 138 | 139 | 140 |
145 |
154 | 163 | 166 | 169 | 0 170 | 171 | 172 |
184 | 185 |
186 | 189 | 192 | 1 193 | 194 | 195 |
196 |
199 | 211 | 217 | 229 |
230 |
231 |
235 |
236 | 256 |
257 |
258 |
259 |
260 |
263 |

266 | 272 | 278 | 281 | 284 | 285 |

286 | 298 |
299 |
302 |
305 |
308 |
309 |
310 |
, 311 |
314 |
317 |

320 | 326 | 332 | 335 | 338 | 339 |

340 | 352 |
353 |
356 |
359 |
360 |
, 361 | ] 362 | `; 363 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/NetworkDisplayer.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`NetworkDisplayer Renders correctly 1`] = ` 4 | Array [ 5 |
8 |

11 | 17 | 23 | 26 | 29 | 30 |

31 | 43 |
, 44 |
47 |
50 |
, 51 | ] 52 | `; 53 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/StoreDisplayer.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`StoreDisplayer Renders correctly 1`] = ` 4 | Array [ 5 |
8 |

11 | 17 | 23 | 26 | 29 | 30 |

31 | 111 |
, 112 |
115 |
118 |
121 |
122 |
, 123 | ] 124 | `; 125 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/StoreTimeline.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`StoreTimeline Renders correctly 1`] = ` 4 | Array [ 5 |
8 |
11 |
14 | 21 | 27 |
28 |
29 |
32 |
36 |
44 | 47 | 50 | 0 51 | 52 | 53 |
58 |
67 | 76 | 79 | 82 | 0 83 | 84 | 85 |
97 | 98 |
99 | 102 | 105 | 1 106 | 107 | 108 |
109 |
112 | 124 | 130 | 142 |
143 |
144 |
148 |
149 | 169 |
170 |
171 |
172 |
, 173 |
176 |

179 | 185 | 191 | 194 | 197 | 198 |

199 | 211 |
, 212 |
215 |
218 |
221 |
222 |
, 223 | ] 224 | `; 225 | -------------------------------------------------------------------------------- /__tests__/bridge.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow 8 | */ 9 | 10 | describe('Bridge', () => { 11 | let Bridge; 12 | 13 | beforeEach(() => { 14 | Bridge = require('../src/bridge').default; 15 | }); 16 | 17 | it('should shutdown properly', () => { 18 | const wall = { 19 | listen: jest.fn(() => () => {}), 20 | send: jest.fn() 21 | }; 22 | const bridge = new Bridge(wall); 23 | 24 | // Check that we're wired up correctly. 25 | bridge.send('init'); 26 | jest.runAllTimers(); 27 | expect(wall.send).toHaveBeenCalledWith('init', undefined, undefined); 28 | 29 | // Should flush pending messages and then shut down. 30 | wall.send.mockClear(); 31 | bridge.send('update', '1'); 32 | bridge.send('update', '2'); 33 | bridge.shutdown(); 34 | jest.runAllTimers(); 35 | expect(wall.send).toHaveBeenCalledWith('update', '1', undefined); 36 | expect(wall.send).toHaveBeenCalledWith('update', '2', undefined); 37 | expect(wall.send).toHaveBeenCalledWith('shutdown', undefined, undefined); 38 | 39 | // Verify that the Bridge doesn't send messages after shutdown. 40 | spyOn(console, 'warn'); 41 | wall.send.mockClear(); 42 | bridge.send('should not send'); 43 | jest.runAllTimers(); 44 | expect(wall.send).not.toHaveBeenCalled(); 45 | expect(console.warn).toHaveBeenCalledWith( 46 | 'Cannot send message "should not send" through a Bridge that has been shutdown.' 47 | ); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /__tests__/global-setup.js: -------------------------------------------------------------------------------- 1 | module.exports = async () => { 2 | process.env.TZ = 'UTC'; 3 | }; 4 | -------------------------------------------------------------------------------- /__tests__/store.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow 8 | */ 9 | 10 | describe('Store', () => { 11 | let Store; 12 | let Bridge; 13 | 14 | beforeEach(() => { 15 | Bridge = require('../src/bridge').default; 16 | Store = require('../src/devtools/store').default; 17 | }); 18 | 19 | it('should delete individual records correctly', () => { 20 | const wall = { 21 | listen: jest.fn(() => () => {}), 22 | send: jest.fn() 23 | }; 24 | const bridge = new Bridge(wall); 25 | const store = new Store(bridge); 26 | 27 | store.mergeRecords(1, { 28 | Bob: { 29 | __id: 'Bob', 30 | __typename: 'User', 31 | profile_pic: 'a_different_url' 32 | }, 33 | Lisa: { 34 | __id: 'Lisa', 35 | __typename: 'User', 36 | profile_pic: 'a_different_url' 37 | }, 38 | user: { 39 | __id: 'user', 40 | __typename: 'User', 41 | profile_pic: 'new_url' 42 | } 43 | }); 44 | 45 | expect(store.getRecords(1)).toEqual({ 46 | Bob: { 47 | __id: 'Bob', 48 | __typename: 'User', 49 | profile_pic: 'a_different_url' 50 | }, 51 | Lisa: { 52 | __id: 'Lisa', 53 | __typename: 'User', 54 | profile_pic: 'a_different_url' 55 | }, 56 | user: { 57 | __id: 'user', 58 | __typename: 'User', 59 | profile_pic: 'new_url' 60 | } 61 | }); 62 | 63 | store.removeRecord(1, 'Lisa'); 64 | store.removeRecord(1, 'Bob'); 65 | 66 | expect(store.getRecords(1)).toEqual({ 67 | user: { 68 | __id: 'user', 69 | __typename: 'User', 70 | profile_pic: 'new_url' 71 | } 72 | }); 73 | }); 74 | 75 | it('should merge records correctly', () => { 76 | const wall = { 77 | listen: jest.fn(() => () => {}), 78 | send: jest.fn() 79 | }; 80 | const bridge = new Bridge(wall); 81 | const store = new Store(bridge); 82 | 83 | // Testing case when oldRecords is null and we just set the map to the newRecords 84 | store.mergeRecords(1, { user: { __id: 'user', __typename: 'User' } }); 85 | 86 | expect(store.getRecords(1)).toEqual({ 87 | user: { __id: 'user', __typename: 'User' } 88 | }); 89 | 90 | // Testing case when newRecords is null/undefined and we don't change anything 91 | store.mergeRecords(1, null); 92 | 93 | expect(store.getRecords(1)).toEqual({ 94 | user: { __id: 'user', __typename: 'User' } 95 | }); 96 | 97 | store.mergeRecords(1, undefined); 98 | 99 | expect(store.getRecords(1)).toEqual({ 100 | user: { __id: 'user', __typename: 'User' } 101 | }); 102 | 103 | // Testing multiple environments 104 | store.mergeRecords(2, { user: { __id: 'user', __typename: 'User' } }); 105 | 106 | expect(store.getRecords(1)).toEqual({ 107 | user: { __id: 'user', __typename: 'User' } 108 | }); 109 | 110 | expect(store.getRecords(2)).toEqual({ 111 | user: { __id: 'user', __typename: 'User' } 112 | }); 113 | 114 | // Testing multiple records 115 | store.mergeRecords(1, { 116 | Jonathan: { 117 | __id: 'Jonathan', 118 | __typename: 'User', 119 | profile_pic: 'some_url' 120 | } 121 | }); 122 | 123 | expect(store.getRecords(1)).toEqual({ 124 | Jonathan: { 125 | __id: 'Jonathan', 126 | __typename: 'User', 127 | profile_pic: 'some_url' 128 | }, 129 | user: { __id: 'user', __typename: 'User' } 130 | }); 131 | 132 | //Testing overwriting a record 133 | store.mergeRecords(1, { 134 | Jonathan: { 135 | __id: 'Jonathan', 136 | __typename: 'User', 137 | profile_pic: 'a_different_url' 138 | } 139 | }); 140 | 141 | expect(store.getRecords(1)).toEqual({ 142 | Jonathan: { 143 | __id: 'Jonathan', 144 | __typename: 'User', 145 | profile_pic: 'a_different_url' 146 | }, 147 | user: { __id: 'user', __typename: 'User' } 148 | }); 149 | 150 | store.mergeRecords(1, { 151 | Bob: { 152 | __id: 'Jonathan', 153 | __typename: 'User', 154 | profile_pic: 'a_different_url' 155 | }, 156 | Lisa: { 157 | __id: 'Lisa', 158 | __typename: 'User', 159 | profile_pic: 'a_different_url' 160 | }, 161 | user: { 162 | __id: 'user', 163 | __typename: 'User', 164 | profile_pic: 'new_url' 165 | } 166 | }); 167 | 168 | expect(store.getRecords(1)).toEqual({ 169 | Jonathan: { 170 | __id: 'Jonathan', 171 | __typename: 'User', 172 | profile_pic: 'a_different_url' 173 | }, 174 | Bob: { 175 | __id: 'Jonathan', 176 | __typename: 'User', 177 | profile_pic: 'a_different_url' 178 | }, 179 | Lisa: { 180 | __id: 'Lisa', 181 | __typename: 'User', 182 | profile_pic: 'a_different_url' 183 | }, 184 | user: { 185 | __id: 'user', 186 | __typename: 'User', 187 | profile_pic: 'new_url' 188 | } 189 | }); 190 | 191 | expect(store.getRecords(2)).toEqual({ 192 | user: { __id: 'user', __typename: 'User' } 193 | }); 194 | 195 | store.mergeRecords(1, { 196 | Jonathan: { 197 | __id: 'Jonathan', 198 | __typename: 'User', 199 | nickname: 'Zuck' 200 | }, 201 | Bob: { 202 | __id: 'Jonathan', 203 | __typename: 'User', 204 | profile_pic: 'a_different_url' 205 | }, 206 | Lisa: { 207 | __id: 'Lisa', 208 | __typename: 'User', 209 | profile_pic: 'a_different_url' 210 | }, 211 | user: { 212 | __id: 'user', 213 | __typename: 'User', 214 | profile_pic: 'new_url' 215 | } 216 | }); 217 | 218 | expect(store.getRecords(1)).toEqual({ 219 | Jonathan: { 220 | __id: 'Jonathan', 221 | __typename: 'User', 222 | profile_pic: 'a_different_url', 223 | nickname: 'Zuck' 224 | }, 225 | Bob: { 226 | __id: 'Jonathan', 227 | __typename: 'User', 228 | profile_pic: 'a_different_url' 229 | }, 230 | Lisa: { 231 | __id: 'Lisa', 232 | __typename: 'User', 233 | profile_pic: 'a_different_url' 234 | }, 235 | user: { 236 | __id: 'user', 237 | __typename: 'User', 238 | profile_pic: 'new_url' 239 | } 240 | }); 241 | 242 | expect(store.getRecords(2)).toEqual({ 243 | user: { __id: 'user', __typename: 'User' } 244 | }); 245 | 246 | // Deleting records 247 | store.mergeRecords(1, { 248 | Bob: null, 249 | Lisa: null, 250 | user: { 251 | __id: 'user', 252 | __typename: 'User', 253 | profile_pic: 'new_url' 254 | } 255 | }); 256 | 257 | expect(store.getRecords(1)).toEqual({ 258 | Jonathan: { 259 | __id: 'Jonathan', 260 | __typename: 'User', 261 | profile_pic: 'a_different_url', 262 | nickname: 'Zuck' 263 | }, 264 | user: { 265 | __id: 'user', 266 | __typename: 'User', 267 | profile_pic: 'new_url' 268 | } 269 | }); 270 | }); 271 | 272 | it('should merge optimistic updates correctly', () => { 273 | const wall = { 274 | listen: jest.fn(() => () => {}), 275 | send: jest.fn() 276 | }; 277 | const bridge = new Bridge(wall); 278 | const store = new Store(bridge); 279 | 280 | // Testing with a real optimistic source 281 | // Testing case when oldRecords is null and we just set the map to the newRecords 282 | store.mergeOptimisticRecords(1, { 283 | 'Ym9va21hcms6MTAwMDAxNzg1MzU1MDU0OjY0NDcxNTQ0NTY1MDkyNDoyNTAxMDA4NjU3MDg1NDU60g==': { 284 | 'unread_count(bookmark_render_location:"COMET_LEFT_NAV")': 0, 285 | 'unread_count(bookmark_render_location:"COMET_TOP_TAB")': 0, 286 | 'unread_count_string(bookmark_render_location:"COMET_LEFT_NAV")': null, 287 | __id: 'Ym9va21hcms6MTAwMDAxNzg1MzU1MDU0OjY0NDcxNTQ0NTY1MDkyNDoyNTAxMDA4NjU3MDg1NDU60g==', 288 | __typename: 'Bookmark' 289 | } 290 | }); 291 | 292 | expect(store.getOptimisticUpdates(1)).toEqual({ 293 | 'Ym9va21hcms6MTAwMDAxNzg1MzU1MDU0OjY0NDcxNTQ0NTY1MDkyNDoyNTAxMDA4NjU3MDg1NDU60g==': { 294 | 'unread_count(bookmark_render_location:"COMET_LEFT_NAV")': 0, 295 | 'unread_count(bookmark_render_location:"COMET_TOP_TAB")': 0, 296 | 'unread_count_string(bookmark_render_location:"COMET_LEFT_NAV")': null, 297 | __id: 'Ym9va21hcms6MTAwMDAxNzg1MzU1MDU0OjY0NDcxNTQ0NTY1MDkyNDoyNTAxMDA4NjU3MDg1NDU60g==', 298 | __typename: 'Bookmark' 299 | } 300 | }); 301 | 302 | // Testing case when newRecords is null/undefined and we don't change anything 303 | store.mergeOptimisticRecords(1, null); 304 | jest.runAllTimers(); 305 | 306 | expect(store.getOptimisticUpdates(1)).toEqual({ 307 | 'Ym9va21hcms6MTAwMDAxNzg1MzU1MDU0OjY0NDcxNTQ0NTY1MDkyNDoyNTAxMDA4NjU3MDg1NDU60g==': { 308 | 'unread_count(bookmark_render_location:"COMET_LEFT_NAV")': 0, 309 | 'unread_count(bookmark_render_location:"COMET_TOP_TAB")': 0, 310 | 'unread_count_string(bookmark_render_location:"COMET_LEFT_NAV")': null, 311 | __id: 'Ym9va21hcms6MTAwMDAxNzg1MzU1MDU0OjY0NDcxNTQ0NTY1MDkyNDoyNTAxMDA4NjU3MDg1NDU60g==', 312 | __typename: 'Bookmark' 313 | } 314 | }); 315 | 316 | store.mergeOptimisticRecords(1, undefined); 317 | jest.runAllTimers(); 318 | 319 | expect(store.getOptimisticUpdates(1)).toEqual({ 320 | 'Ym9va21hcms6MTAwMDAxNzg1MzU1MDU0OjY0NDcxNTQ0NTY1MDkyNDoyNTAxMDA4NjU3MDg1NDU60g==': { 321 | 'unread_count(bookmark_render_location:"COMET_LEFT_NAV")': 0, 322 | 'unread_count(bookmark_render_location:"COMET_TOP_TAB")': 0, 323 | 'unread_count_string(bookmark_render_location:"COMET_LEFT_NAV")': null, 324 | __id: 'Ym9va21hcms6MTAwMDAxNzg1MzU1MDU0OjY0NDcxNTQ0NTY1MDkyNDoyNTAxMDA4NjU3MDg1NDU60g==', 325 | __typename: 'Bookmark' 326 | } 327 | }); 328 | 329 | // Removing all optimistic updates 330 | // Simulating the store.restore event 331 | 332 | store.clearOptimisticUpdates(1); 333 | jest.runAllTimers(); 334 | 335 | expect(store.getRecords(1)).toEqual(undefined); 336 | 337 | // Testing multiple records 338 | store.mergeOptimisticRecords(1, { 339 | Jonathan: { 340 | __id: 'Jonathan', 341 | __typename: 'User', 342 | profile_pic: 'some_url' 343 | }, 344 | Lilly: { 345 | __id: 'Lilly', 346 | __typename: 'User', 347 | profile_pic: 'url' 348 | } 349 | }); 350 | jest.runAllTimers(); 351 | 352 | expect(store.getOptimisticUpdates(1)).toEqual({ 353 | Jonathan: { 354 | __id: 'Jonathan', 355 | __typename: 'User', 356 | profile_pic: 'some_url' 357 | }, 358 | Lilly: { 359 | __id: 'Lilly', 360 | __typename: 'User', 361 | profile_pic: 'url' 362 | } 363 | }); 364 | 365 | //Testing overwriting a record 366 | store.mergeOptimisticRecords(1, { 367 | Jonathan: { 368 | __id: 'Jonathan', 369 | __typename: 'User', 370 | profile_pic: 'a_different_url' 371 | } 372 | }); 373 | jest.runAllTimers(); 374 | 375 | expect(store.getOptimisticUpdates(1)).toEqual({ 376 | Jonathan: { 377 | __id: 'Jonathan', 378 | __typename: 'User', 379 | profile_pic: 'a_different_url' 380 | }, 381 | Lilly: { 382 | __id: 'Lilly', 383 | __typename: 'User', 384 | profile_pic: 'url' 385 | } 386 | }); 387 | 388 | store.mergeOptimisticRecords(1, { 389 | Jonathan: { 390 | __id: 'Jonathan', 391 | __typename: 'User', 392 | nickname: 'Zuck' 393 | }, 394 | Bob: { 395 | __id: 'Jonathan', 396 | __typename: 'User', 397 | profile_pic: 'a_different_url' 398 | }, 399 | user: { 400 | __id: 'user', 401 | __typename: 'User', 402 | profile_pic: 'new_url' 403 | } 404 | }); 405 | jest.runAllTimers(); 406 | 407 | expect(store.getOptimisticUpdates(1)).toEqual({ 408 | Jonathan: { 409 | __id: 'Jonathan', 410 | __typename: 'User', 411 | profile_pic: 'a_different_url', 412 | nickname: 'Zuck' 413 | }, 414 | Bob: { 415 | __id: 'Jonathan', 416 | __typename: 'User', 417 | profile_pic: 'a_different_url' 418 | }, 419 | Lilly: { 420 | __id: 'Lilly', 421 | __typename: 'User', 422 | profile_pic: 'url' 423 | }, 424 | user: { 425 | __id: 'user', 426 | __typename: 'User', 427 | profile_pic: 'new_url' 428 | } 429 | }); 430 | }); 431 | }); 432 | -------------------------------------------------------------------------------- /assets/network.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/protostar-relay/ec442a7c2a9f855d07e654a76acd1173759a33b4/assets/network.gif -------------------------------------------------------------------------------- /assets/protologo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/protostar-relay/ec442a7c2a9f855d07e654a76acd1173759a33b4/assets/protologo.png -------------------------------------------------------------------------------- /assets/protorelaystore.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/protostar-relay/ec442a7c2a9f855d07e654a76acd1173759a33b4/assets/protorelaystore.gif -------------------------------------------------------------------------------- /assets/protostar-mutations.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/protostar-relay/ec442a7c2a9f855d07e654a76acd1173759a33b4/assets/protostar-mutations.gif -------------------------------------------------------------------------------- /assets/protostar-records-filter.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/protostar-relay/ec442a7c2a9f855d07e654a76acd1173759a33b4/assets/protostar-records-filter.gif -------------------------------------------------------------------------------- /assets/searchbar.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/protostar-relay/ec442a7c2a9f855d07e654a76acd1173759a33b4/assets/searchbar.gif -------------------------------------------------------------------------------- /assets/snapshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/protostar-relay/ec442a7c2a9f855d07e654a76acd1173759a33b4/assets/snapshot.gif -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const chromeManifest = require('./shells/browser/chrome/manifest.json'); 9 | 10 | 11 | const minChromeVersion = parseInt(chromeManifest.minimum_chrome_version, 10); 12 | 13 | validateVersion(minChromeVersion); 14 | 15 | 16 | function validateVersion(version) { 17 | if (version > 0 && version < 200) { 18 | return; 19 | } 20 | throw new Error('Suspicious browser version in manifest: ' + version); 21 | } 22 | 23 | module.exports = api => { 24 | const isTest = api.env('test'); 25 | const targets = {}; 26 | if (isTest) { 27 | targets.node = 'current'; 28 | } else { 29 | targets.chrome = minChromeVersion.toString(); 30 | 31 | // This targets RN/Hermes. 32 | targets.ie = '11'; 33 | } 34 | const plugins = [ 35 | ['relay'], 36 | ['@babel/plugin-proposal-optional-chaining'], 37 | ['@babel/plugin-transform-flow-strip-types'], 38 | ['@babel/plugin-proposal-class-properties', { loose: false }], 39 | ]; 40 | if (process.env.NODE_ENV !== 'production') { 41 | plugins.push(['@babel/plugin-transform-react-jsx-source']); 42 | } 43 | return { 44 | plugins, 45 | presets: [ 46 | ['@babel/preset-env', { targets }], 47 | '@babel/preset-react', 48 | '@babel/preset-flow', 49 | ], 50 | }; 51 | }; 52 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.0' 2 | services: 3 | test: 4 | image: 'protorelay/protostar' 5 | container_name: 'protostar-test' 6 | volumes: 7 | - .:/usr/src/app 8 | - node_modules:/usr/src/app/node_modules 9 | command: npm run test 10 | volumes: 11 | node_modules: {} -------------------------------------------------------------------------------- /flow.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | declare module 'events' { 9 | declare class EventEmitter { 10 | addListener>( 11 | event: Event, 12 | listener: (...$ElementType) => any 13 | ): void; 14 | emit: >( 15 | event: Event, 16 | ...$ElementType 17 | ) => void; 18 | removeListener(event: $Keys, listener: Function): void; 19 | removeAllListeners(event?: $Keys): void; 20 | } 21 | 22 | declare export default typeof EventEmitter; 23 | } 24 | 25 | declare var __DEV__: boolean; 26 | 27 | declare var jasmine: {| 28 | getEnv: () => {| 29 | afterEach: (callback: Function) => void, 30 | beforeEach: (callback: Function) => void, 31 | |}, 32 | |}; 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "name": "protostar-relay", 4 | "repository": "oslabs-beta/protostar-relay", 5 | "license": "MIT", 6 | "private": true, 7 | "workspaces": [ 8 | "packages/*" 9 | ], 10 | "scripts": { 11 | "build": "cross-env NODE_ENV=production node ./shells/browser/chrome/build", 12 | "test": "cross-env TZ=\"UTC\" jest", 13 | "install-app": "npm i --prefix ./relay-examples/todo/", 14 | "start-test-app": "npm start --prefix ./relay-examples/todo/", 15 | "watch:chrome:frontend": "cross-env NODE_ENV=development node ./shells/browser/chrome/watch", 16 | "docker-test": "docker-compose up" 17 | }, 18 | "jest": { 19 | "verbose": true, 20 | "testRegex": "((\\.|/*.)(spec))\\.js?$", 21 | "moduleNameMapper": { 22 | "\\.(css|less)$": "/__tests__/__mocks__/styleMock.js" 23 | }, 24 | "timers": "fake", 25 | "globalSetup": "/__tests__/global-setup.js" 26 | }, 27 | "devEngines": { 28 | "node": "10.x || 11.x" 29 | }, 30 | "lint-staged": { 31 | "{shells,src}/**/*.{js,json,css}": [ 32 | "prettier --write", 33 | "git add" 34 | ], 35 | "**/*.js": "eslint --max-warnings 0" 36 | }, 37 | "devDependencies": { 38 | "@babel/core": "^7.7.5", 39 | "@babel/plugin-proposal-class-properties": "^7.7.4", 40 | "@babel/plugin-proposal-optional-chaining": "^7.7.5", 41 | "@babel/plugin-transform-flow-strip-types": "^7.7.4", 42 | "@babel/plugin-transform-react-jsx-source": "^7.7.4", 43 | "@babel/preset-env": "^7.7.6", 44 | "@babel/preset-flow": "^7.7.4", 45 | "@babel/preset-react": "^7.7.4", 46 | "@reach/menu-button": "^0.5.4", 47 | "@reach/tooltip": "^0.5.4", 48 | "archiver": "^3.0.0", 49 | "babel-core": "^7.0.0-bridge", 50 | "babel-eslint": "^10.0.3", 51 | "babel-jest": "^24.9.0", 52 | "babel-loader": "^8.0.6", 53 | "babel-plugin-relay": "master", 54 | "chance": "^1.0.18", 55 | "child-process-promise": "^2.2.1", 56 | "chrome-launch": "^1.1.4", 57 | "classnames": "2.2.1", 58 | "clipboard-js": "^0.3.6", 59 | "cross-env": "^6.0.3", 60 | "crx": "^5.0.0", 61 | "css-loader": "^1.0.1", 62 | "es6-symbol": "3.0.2", 63 | "eslint": "^6.6.0", 64 | "eslint-config-prettier": "^6.5.0", 65 | "eslint-config-react-app": "^5.0.2", 66 | "eslint-plugin-flowtype": "^4.3.0", 67 | "eslint-plugin-import": "^2.18.2", 68 | "eslint-plugin-jsx-a11y": "^6.2.3", 69 | "eslint-plugin-prettier": "^3.1.1", 70 | "eslint-plugin-react": "^7.16.0", 71 | "eslint-plugin-react-hooks": "^2.2.0", 72 | "events": "^3.0.0", 73 | "flow-bin": "^0.113.0", 74 | "fs-extra": "^3.0.1", 75 | "graphql": "^14.4.2", 76 | "jest": "^24.9.0", 77 | "lint-staged": "^7.0.5", 78 | "local-storage-fallback": "^4.1.1", 79 | "lodash.throttle": "^4.1.1", 80 | "log-update": "^2.0.0", 81 | "lru-cache": "^4.1.3", 82 | "nullthrows": "^1.0.0", 83 | "object-assign": "4.0.1", 84 | "opener": "^1.5.1", 85 | "prettier": "^1.19.1", 86 | "prop-types": "^15.7.2", 87 | "react": "^0.0.0-50b50c26f", 88 | "react-dom": "^0.0.0-50b50c26f", 89 | "react-relay": "master", 90 | "react-test-renderer": "0.0.0-fec00a869", 91 | "react-virtualized-auto-sizer": "^1.0.2", 92 | "relay-compiler": "master", 93 | "relay-config": "master", 94 | "rimraf": "^2.6.3", 95 | "sass-loader": "^10.0.2", 96 | "scheduler": "^0.0.0-50b50c26f", 97 | "style-loader": "^0.23.1", 98 | "web-ext": "^3.0.0", 99 | "webpack": "^4.41.3", 100 | "webpack-cli": "^3.1.2", 101 | "webpack-dev-server": "^3.10.0", 102 | "yargs": "^14.2.0" 103 | }, 104 | "dependencies": { 105 | "bulma": "^0.9.0", 106 | "enzyme": "^3.11.0", 107 | "enzyme-adapter-react-16": "^1.15.4", 108 | "node-sass": "^4.14.1", 109 | "react-input-range": "^1.3.0", 110 | "sass": "^1.26.10" 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /shells/browser/chrome/build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Copyright (c) Facebook, Inc. and its affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | */ 8 | 9 | const chalk = require('chalk'); 10 | const { execSync } = require('child_process'); 11 | const { existsSync } = require('fs'); 12 | const { isAbsolute, join, relative } = require('path'); 13 | const { argv } = require('yargs'); 14 | const build = require('../shared/build'); 15 | 16 | const main = async () => { 17 | const { crx, keyPath } = argv; 18 | 19 | if (crx) { 20 | if (!keyPath || !existsSync(keyPath)) { 21 | console.error('Must specify a key file (.pem) to build CRX'); 22 | process.exit(1); 23 | } 24 | } 25 | 26 | await build('chrome'); 27 | 28 | if (crx) { 29 | const cwd = join(__dirname, 'build'); 30 | 31 | let safeKeyPath = keyPath; 32 | if (!isAbsolute(keyPath)) { 33 | safeKeyPath = join(relative(cwd, process.cwd()), keyPath); 34 | } 35 | 36 | execSync(`crx pack ./unpacked -o RelayDevTools.crx -p ${safeKeyPath}`, { 37 | cwd, 38 | }); 39 | } 40 | 41 | console.log(chalk.green('\nThe Chrome extension has been built!')); 42 | console.log(chalk.green('You can test this build by running:')); 43 | console.log(chalk.gray('\n# From the relay-devtools root directory:')); 44 | console.log('yarn run test:chrome'); 45 | }; 46 | 47 | main(); 48 | -------------------------------------------------------------------------------- /shells/browser/chrome/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Proto Relay", 4 | "description": "Adds Relay debugging tools to the Chrome DevTool panel", 5 | "version": "1.0.0", 6 | "version_name": "1.0.0", 7 | "update_url": "https://github.com/oslabs-beta/protostar-relay", 8 | "minimum_chrome_version": "78", 9 | "icons": { 10 | "16": "assets/proto16.png", 11 | "32": "assets/proto32.png", 12 | "48": "assets/proto48.png", 13 | "128": "assets/proto128.png" 14 | }, 15 | "browser_action": { 16 | "default_icon": { 17 | "16": "assets/proto16.png", 18 | "32": "assets/proto32.png", 19 | "48": "assets/proto48.png", 20 | "128": "assets/proto128.png" 21 | }, 22 | "default_popup": "popups/disabled.html" 23 | }, 24 | "devtools_page": "main.html", 25 | "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", 26 | "web_accessible_resources": [ 27 | "main.html", 28 | "build/backend.js", 29 | "build/renderer.js", 30 | "assets/protorelay.png" 31 | ], 32 | "background": { 33 | "scripts": [ 34 | "build/background.js" 35 | ], 36 | "persistent": false 37 | }, 38 | "permissions": [ 39 | "file:///*", 40 | "http://*/*", 41 | "https://*/*", 42 | "webNavigation" 43 | ], 44 | "content_scripts": [ 45 | { 46 | "matches": [ 47 | "" 48 | ], 49 | "js": [ 50 | "build/injectGlobalHook.js" 51 | ], 52 | "run_at": "document_start" 53 | } 54 | ] 55 | } -------------------------------------------------------------------------------- /shells/browser/chrome/nottest.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Copyright (c) Facebook, Inc. and its affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | */ 8 | 9 | const chromeLaunch = require('chrome-launch'); // eslint-disable-line import/no-extraneous-dependencies 10 | const { resolve } = require('path'); 11 | 12 | const EXTENSION_PATH = resolve('shells/browser/chrome/build/unpacked'); 13 | const START_URL = 'https://facebook.github.io/react/'; 14 | 15 | chromeLaunch(START_URL, { 16 | args: [`--load-extension=${EXTENSION_PATH}`], 17 | }); 18 | -------------------------------------------------------------------------------- /shells/browser/chrome/now.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "relay-devtools-experimental-chrome", 3 | "alias": ["relay-devtools-experimental-chrome"], 4 | "files": ["index.html", "RelayDevTools.zip"] 5 | } 6 | -------------------------------------------------------------------------------- /shells/browser/chrome/watch.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Copyright (c) Facebook, Inc. and its affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | */ 8 | 9 | const { execSync } = require('child_process'); 10 | const { join } = require('path'); 11 | 12 | 13 | const webpackPath = join( 14 | __dirname, 15 | '..', 16 | '..', 17 | '..', 18 | 'node_modules', 19 | '.bin', 20 | 'webpack' 21 | ); 22 | const binPath = join(__dirname, 'build', 'unpacked', 'build'); 23 | execSync( 24 | `${webpackPath} --config ../shared/webpack.config.js --output-path ${binPath} --watch`, 25 | { 26 | cwd: join(__dirname, '..', 'shared'), 27 | env: process.env, 28 | stdio: 'inherit', 29 | } 30 | ); 31 | -------------------------------------------------------------------------------- /shells/browser/shared/assets/proto128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/protostar-relay/ec442a7c2a9f855d07e654a76acd1173759a33b4/shells/browser/shared/assets/proto128.png -------------------------------------------------------------------------------- /shells/browser/shared/assets/proto16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/protostar-relay/ec442a7c2a9f855d07e654a76acd1173759a33b4/shells/browser/shared/assets/proto16.png -------------------------------------------------------------------------------- /shells/browser/shared/assets/proto32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/protostar-relay/ec442a7c2a9f855d07e654a76acd1173759a33b4/shells/browser/shared/assets/proto32.png -------------------------------------------------------------------------------- /shells/browser/shared/assets/proto48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/protostar-relay/ec442a7c2a9f855d07e654a76acd1173759a33b4/shells/browser/shared/assets/proto48.png -------------------------------------------------------------------------------- /shells/browser/shared/assets/protorelay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/protostar-relay/ec442a7c2a9f855d07e654a76acd1173759a33b4/shells/browser/shared/assets/protorelay.png -------------------------------------------------------------------------------- /shells/browser/shared/build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Copyright (c) Facebook, Inc. and its affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | */ 8 | 9 | const archiver = require('archiver'); 10 | const { execSync } = require('child_process'); 11 | const { readFileSync, writeFileSync, createWriteStream } = require('fs'); 12 | const { copy, ensureDir, move, remove } = require('fs-extra'); 13 | const { join } = require('path'); 14 | const { getCommit } = require('../../utils'); 15 | 16 | // These files are copied along with Webpack-bundled files 17 | // to produce the final web extension 18 | const STATIC_FILES = ['assets', 'main.html', 'index.html']; 19 | 20 | const preProcess = async (destinationPath, tempPath) => { 21 | await remove(destinationPath); // Clean up from previously completed builds 22 | await remove(tempPath); // Clean up from any previously failed builds 23 | await ensureDir(tempPath); // Create temp dir for this new build 24 | }; 25 | 26 | const build = async (tempPath, manifestPath) => { 27 | const binPath = join(tempPath, 'bin'); 28 | const zipPath = join(tempPath, 'zip'); 29 | 30 | const webpackPath = join(__dirname, '..', '..', '..', 'node_modules', '.bin', 'webpack'); 31 | execSync(`${webpackPath} --config webpack.config.js --output-path ${binPath}`, { 32 | cwd: __dirname, 33 | env: process.env, 34 | stdio: 'inherit' 35 | }); 36 | execSync(`${webpackPath} --config webpack.backend.js --output-path ${binPath}`, { 37 | cwd: __dirname, 38 | env: process.env, 39 | stdio: 'inherit' 40 | }); 41 | 42 | // Make temp dir 43 | await ensureDir(zipPath); 44 | 45 | const copiedManifestPath = join(zipPath, 'manifest.json'); 46 | 47 | // Copy unbuilt source files to zip dir to be packaged: 48 | await copy(binPath, join(zipPath, 'build')); 49 | await copy(manifestPath, copiedManifestPath); 50 | await Promise.all(STATIC_FILES.map(file => copy(join(__dirname, file), join(zipPath, file)))); 51 | 52 | const commit = getCommit(); 53 | const versionDateString = `${commit} (${new Date().toLocaleDateString()})`; 54 | 55 | const manifest = JSON.parse(readFileSync(copiedManifestPath).toString()); 56 | if (manifest.version_name) { 57 | manifest.version_name = versionDateString; 58 | } else { 59 | manifest.description += `\n\nCreated from revision ${versionDateString}`; 60 | } 61 | 62 | writeFileSync(copiedManifestPath, JSON.stringify(manifest, null, 2)); 63 | 64 | // Pack the extension 65 | const archive = archiver('zip', { zlib: { level: 9 } }); 66 | const zipStream = createWriteStream(join(tempPath, 'RelayDevTools.zip')); 67 | await new Promise((resolve, reject) => { 68 | archive 69 | .directory(zipPath, false) 70 | .on('error', err => reject(err)) 71 | .pipe(zipStream); 72 | archive.finalize(); 73 | zipStream.on('close', () => resolve()); 74 | }); 75 | }; 76 | 77 | const postProcess = async (tempPath, destinationPath) => { 78 | const unpackedSourcePath = join(tempPath, 'zip'); 79 | const packedSourcePath = join(tempPath, 'RelayDevTools.zip'); 80 | const packedDestPath = join(destinationPath, 'RelayDevTools.zip'); 81 | const unpackedDestPath = join(destinationPath, 'unpacked'); 82 | 83 | await move(unpackedSourcePath, unpackedDestPath); // Copy built files to destination 84 | await move(packedSourcePath, packedDestPath); // Copy built files to destination 85 | await remove(tempPath); // Clean up temp directory and files 86 | }; 87 | 88 | const main = async buildId => { 89 | const root = join(__dirname, '..', buildId); 90 | const manifestPath = join(root, 'manifest.json'); 91 | const destinationPath = join(root, 'build'); 92 | 93 | try { 94 | const tempPath = join(__dirname, 'build', buildId); 95 | await preProcess(destinationPath, tempPath); 96 | await build(tempPath, manifestPath); 97 | 98 | const builtUnpackedPath = join(destinationPath, 'unpacked'); 99 | await postProcess(tempPath, destinationPath); 100 | 101 | return builtUnpackedPath; 102 | } catch (error) { 103 | console.error(error); 104 | process.exit(1); 105 | } 106 | 107 | return null; 108 | }; 109 | 110 | module.exports = main; 111 | -------------------------------------------------------------------------------- /shells/browser/shared/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 15 | 16 | Document 17 | 18 | 19 |
20 |
Unable to find Relay on the page.
21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /shells/browser/shared/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /shells/browser/shared/src/backend.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow 8 | */ 9 | 10 | // Do not use imports or top-level requires here! 11 | // Running module factories is intentionally delayed until we know the hook exists. 12 | // This is to avoid issues like: https://github.com/facebook/react-devtools/issues/1039 13 | 14 | function welcome(event) { 15 | if (event.source !== window || event.data.source !== 'relay-devtools-content-script') { 16 | return; 17 | } 18 | 19 | window.removeEventListener('message', welcome); 20 | 21 | setup(window.__RELAY_DEVTOOLS_HOOK__); 22 | } 23 | 24 | window.addEventListener('message', welcome); 25 | 26 | function setup(hook) { 27 | const Agent = require('src/backend/agent').default; 28 | const Bridge = require('src/bridge').default; 29 | const { initBackend } = require('src/backend'); 30 | 31 | const bridge = new Bridge({ 32 | listen(fn) { 33 | const listener = event => { 34 | if ( 35 | event.source !== window || 36 | !event.data || 37 | event.data.source !== 'relay-devtools-content-script' || 38 | !event.data.payload 39 | ) { 40 | return; 41 | } 42 | fn(event.data.payload); 43 | }; 44 | window.addEventListener('message', listener); 45 | return () => { 46 | window.removeEventListener('message', listener); 47 | }; 48 | }, 49 | send(event: string, payload: any, transferable?: Array) { 50 | window.postMessage( 51 | { 52 | source: 'relay-devtools-bridge', 53 | payload: { event, payload: JSON.parse(JSON.stringify(payload)) } 54 | }, 55 | '*', 56 | transferable 57 | ); 58 | } 59 | }); 60 | 61 | const agent = new Agent(bridge); 62 | agent.addListener('shutdown', () => { 63 | // If we received 'shutdown' from `agent`, we assume the `bridge` is already shutting down, 64 | // and that caused the 'shutdown' event on the `agent`, so we don't need to call `bridge.shutdown()` here. 65 | hook.emit('shutdown'); 66 | }); 67 | 68 | initBackend(hook, agent, window); 69 | } 70 | -------------------------------------------------------------------------------- /shells/browser/shared/src/background.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow 8 | */ 9 | 10 | /* global chrome */ 11 | 12 | const ports = {}; 13 | 14 | chrome.runtime.onConnect.addListener(function(port) { 15 | let tab = null; 16 | let name = null; 17 | if (isNumeric(port.name)) { 18 | tab = port.name; 19 | name = 'devtools'; 20 | installContentScript(+port.name); 21 | } else { 22 | tab = port.sender.tab.id; 23 | name = 'content-script'; 24 | } 25 | 26 | if (!ports[tab]) { 27 | ports[tab] = { 28 | devtools: null, 29 | 'content-script': null 30 | }; 31 | } 32 | ports[tab][name] = port; 33 | 34 | if (ports[tab].devtools && ports[tab]['content-script']) { 35 | doublePipe(ports[tab].devtools, ports[tab]['content-script']); 36 | } 37 | }); 38 | 39 | function isNumeric(str: string): boolean { 40 | return +str + '' === str; 41 | } 42 | 43 | function installContentScript(tabId: number) { 44 | chrome.tabs.executeScript(tabId, { file: '/build/contentScript.js' }, function() {}); 45 | } 46 | 47 | function doublePipe(one, two) { 48 | one.onMessage.addListener(lOne); 49 | function lOne(message) { 50 | two.postMessage(message); 51 | } 52 | two.onMessage.addListener(lTwo); 53 | function lTwo(message) { 54 | one.postMessage(message); 55 | } 56 | function shutdown() { 57 | one.onMessage.removeListener(lOne); 58 | two.onMessage.removeListener(lTwo); 59 | one.disconnect(); 60 | two.disconnect(); 61 | } 62 | one.onDisconnect.addListener(shutdown); 63 | two.onDisconnect.addListener(shutdown); 64 | } 65 | -------------------------------------------------------------------------------- /shells/browser/shared/src/contentScript.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow 8 | */ 9 | 10 | /* global chrome */ 11 | 12 | let backendDisconnected: boolean = false; 13 | let backendInitialized: boolean = false; 14 | 15 | function sayHelloToBackend() { 16 | window.postMessage( 17 | { 18 | source: 'relay-devtools-content-script', 19 | hello: true 20 | }, 21 | '*' 22 | ); 23 | } 24 | 25 | function handleMessageFromDevtools(message) { 26 | window.postMessage( 27 | { 28 | source: 'relay-devtools-content-script', 29 | payload: message 30 | }, 31 | '*' 32 | ); 33 | } 34 | 35 | function handleMessageFromPage(evt) { 36 | if (evt.source === window && evt.data && evt.data.source === 'relay-devtools-bridge') { 37 | backendInitialized = true; 38 | 39 | port.postMessage(evt.data.payload); 40 | } 41 | } 42 | 43 | function handleDisconnect() { 44 | backendDisconnected = true; 45 | 46 | window.removeEventListener('message', handleMessageFromPage); 47 | 48 | window.postMessage( 49 | { 50 | source: 'relay-devtools-content-script', 51 | payload: { 52 | type: 'event', 53 | event: 'shutdown' 54 | } 55 | }, 56 | '*' 57 | ); 58 | } 59 | 60 | // proxy from main page to devtools (via the background page) 61 | var port = chrome.runtime.connect({ 62 | name: 'content-script' 63 | }); 64 | port.onMessage.addListener(handleMessageFromDevtools); 65 | port.onDisconnect.addListener(handleDisconnect); 66 | 67 | window.addEventListener('message', handleMessageFromPage); 68 | 69 | sayHelloToBackend(); 70 | 71 | // The backend waits to install the global hook until notified by the content script. 72 | // In the event of a page reload, the content script might be loaded before the backend is injected. 73 | // Because of this we need to poll the backend until it has been initialized. 74 | if (!backendInitialized) { 75 | const intervalID = setInterval(() => { 76 | if (backendInitialized || backendDisconnected) { 77 | clearInterval(intervalID); 78 | } else { 79 | sayHelloToBackend(); 80 | } 81 | }, 500); 82 | } 83 | -------------------------------------------------------------------------------- /shells/browser/shared/src/inject.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | /* global chrome */ 9 | 10 | export default function inject(scriptName: string, done: ?Function) { 11 | const source = ` 12 | // the prototype stuff is in case document.createElement has been modified 13 | (function () { 14 | var script = document.constructor.prototype.createElement.call(document, 'script'); 15 | script.src = "${scriptName}"; 16 | script.charset = "utf-8"; 17 | document.documentElement.appendChild(script); 18 | script.parentNode.removeChild(script); 19 | })() 20 | `; 21 | 22 | chrome.devtools.inspectedWindow.eval(source, function(response, error) { 23 | if (error) { 24 | console.log(error); 25 | } 26 | 27 | if (typeof done === 'function') { 28 | done(); 29 | } 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /shells/browser/shared/src/injectGlobalHook.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow 8 | */ 9 | 10 | /* global chrome */ 11 | 12 | import nullthrows from 'nullthrows'; 13 | import { installHook } from 'src/hook'; 14 | 15 | function injectCode(code) { 16 | const script = document.createElement('script'); 17 | script.textContent = code; 18 | 19 | // This script runs before the element is created, 20 | // so we add the script to instead. 21 | nullthrows(document.documentElement).appendChild(script); 22 | nullthrows(script.parentNode).removeChild(script); 23 | } 24 | 25 | let lastDetectionResult; 26 | 27 | // We want to detect when a renderer attaches, and notify the "background page" 28 | // (which is shared between tabs and can highlight the React icon). 29 | // Currently we are in "content script" context, so we can't listen to the hook directly 30 | // (it will be injected directly into the page). 31 | // So instead, the hook will use postMessage() to pass message to us here. 32 | // And when this happens, we'll send a message to the "background page". 33 | window.addEventListener('message', function(evt) { 34 | if (evt.source === window && evt.data && evt.data.source === 'relay-devtools-detector') { 35 | lastDetectionResult = { 36 | hasDetectedReact: true 37 | }; 38 | chrome.runtime.sendMessage(lastDetectionResult); 39 | } 40 | }); 41 | 42 | // NOTE: Firefox WebExtensions content scripts are still alive and not re-injected 43 | // while navigating the history to a document that has not been destroyed yet, 44 | // replay the last detection result if the content script is active and the 45 | // document has been hidden and shown again. 46 | window.addEventListener('pageshow', function(evt) { 47 | if (!lastDetectionResult || evt.target !== window.document) { 48 | return; 49 | } 50 | chrome.runtime.sendMessage(lastDetectionResult); 51 | }); 52 | 53 | const detectRelay = ` 54 | window.__RELAY_DEVTOOLS_HOOK__.on('environment', function(evt) { 55 | window.postMessage({ 56 | source: 'relay-devtools-detector', 57 | }, '*'); 58 | }); 59 | `; 60 | 61 | // Inject a `__RELAY_DEVTOOLS_HOOK__` global so that Relay can detect that the 62 | // devtools are installed (and skip its suggestion to install the devtools). 63 | injectCode(';(' + installHook.toString() + '(window))' + detectRelay); 64 | -------------------------------------------------------------------------------- /shells/browser/shared/src/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | /* global chrome */ 9 | 10 | import { createElement } from 'react'; 11 | import { unstable_createRoot as createRoot, flushSync } from 'react-dom'; 12 | import Bridge from 'src/bridge'; 13 | import Store from 'src/devtools/store'; 14 | import inject from './inject'; 15 | import { createViewElementSource } from './utils'; 16 | import DevTools from 'src/devtools/DevTools'; 17 | 18 | let panelCreated = false; 19 | 20 | function createPanelIfReactLoaded() { 21 | if (panelCreated) { 22 | return; 23 | } 24 | 25 | chrome.devtools.inspectedWindow.eval( 26 | 'window.__RELAY_DEVTOOLS_HOOK__ && window.__RELAY_DEVTOOLS_HOOK__.environments.size > 0', 27 | (pageHasRelay, error) => { 28 | if (!pageHasRelay || panelCreated) { 29 | return; 30 | } 31 | 32 | panelCreated = true; 33 | 34 | clearInterval(loadCheckInterval); 35 | 36 | let bridge = null; 37 | let store = null; 38 | 39 | let cloneStyleTags = null; 40 | let render = null; 41 | let root = null; 42 | let currentPanel = null; 43 | 44 | const tabId = chrome.devtools.inspectedWindow.tabId; 45 | 46 | function initBridgeAndStore() { 47 | const port = chrome.runtime.connect({ 48 | name: '' + tabId 49 | }); 50 | // Looks like `port.onDisconnect` does not trigger on in-tab navigation like new URL or back/forward navigation, 51 | // so it makes no sense to handle it here. 52 | 53 | bridge = new Bridge({ 54 | listen(fn) { 55 | const listener = message => fn(message); 56 | // Store the reference so that we unsubscribe from the same object. 57 | const portOnMessage = port.onMessage; 58 | portOnMessage.addListener(listener); 59 | return () => { 60 | portOnMessage.removeListener(listener); 61 | }; 62 | }, 63 | send(event: string, payload: any, transferable?: Array) { 64 | port.postMessage({ event, payload }, transferable); 65 | } 66 | }); 67 | 68 | store = new Store(bridge); 69 | 70 | // Initialize the backend only once the Store has been initialized. 71 | // Otherwise the Store may miss important initial tree op codes. 72 | inject(chrome.runtime.getURL('build/backend.js')); 73 | 74 | const viewElementSourceFunction = createViewElementSource(bridge, store); 75 | 76 | render = () => { 77 | console.log('Rendering...'); 78 | if (root) { 79 | root.render( 80 | createElement(DevTools, { 81 | bridge, 82 | // showTabBar: true, 83 | store, 84 | // viewElementSourceFunction, 85 | rootContainer: currentPanel.container 86 | }) 87 | ); 88 | } 89 | }; 90 | 91 | render(); 92 | } 93 | 94 | cloneStyleTags = () => { 95 | const linkTags = []; 96 | for (const linkTag of document.getElementsByTagName('link')) { 97 | if (linkTag.rel === 'stylesheet') { 98 | const newLinkTag = document.createElement('link'); 99 | for (const attribute of linkTag.attributes) { 100 | newLinkTag.setAttribute(attribute.nodeName, attribute.nodeValue); 101 | } 102 | linkTags.push(newLinkTag); 103 | } 104 | } 105 | return linkTags; 106 | }; 107 | 108 | initBridgeAndStore(); 109 | 110 | function ensureInitialHTMLIsCleared(container) { 111 | if (container._hasInitialHTMLBeenCleared) { 112 | return; 113 | } 114 | container.innerHTML = ''; 115 | container._hasInitialHTMLBeenCleared = true; 116 | } 117 | 118 | chrome.devtools.panels.create('proto*', '', 'index.html', panel => { 119 | panel.onShown.addListener(listenPanel => { 120 | if (currentPanel === listenPanel) { 121 | return; 122 | } 123 | currentPanel = listenPanel; 124 | 125 | if (listenPanel.container != null) { 126 | listenPanel.injectStyles(cloneStyleTags); 127 | ensureInitialHTMLIsCleared(listenPanel.container); 128 | root = createRoot(listenPanel.container); 129 | render(); 130 | } 131 | }); 132 | panel.onHidden.addListener(() => { 133 | // TODO: Stop highlighting and stuff. 134 | }); 135 | }); 136 | 137 | chrome.devtools.network.onNavigated.removeListener(checkPageForReact); 138 | 139 | // Shutdown bridge before a new page is loaded. 140 | chrome.webNavigation.onBeforeNavigate.addListener(function onBeforeNavigate(details) { 141 | // Ignore navigation events from other tabs (or from within frames). 142 | if (details.tabId !== tabId || details.frameId !== 0) { 143 | return; 144 | } 145 | 146 | // `bridge.shutdown()` will remove all listeners we added, so we don't have to. 147 | bridge.shutdown(); 148 | }); 149 | 150 | // Re-initialize DevTools panel when a new page is loaded. 151 | chrome.devtools.network.onNavigated.addListener(function onNavigated() { 152 | // It's easiest to recreate the DevTools panel (to clean up potential stale state). 153 | // We can revisit this in the future as a small optimization. 154 | flushSync(() => { 155 | root.unmount(() => { 156 | initBridgeAndStore(); 157 | }); 158 | }); 159 | }); 160 | } 161 | ); 162 | } 163 | 164 | // Load (or reload) the DevTools extension when the user navigates to a new page. 165 | function checkPageForReact() { 166 | createPanelIfReactLoaded(); 167 | } 168 | 169 | chrome.devtools.network.onNavigated.addListener(checkPageForReact); 170 | 171 | // Check to see if React has loaded once per second in case React is added 172 | // after page load 173 | const loadCheckInterval = setInterval(function() { 174 | createPanelIfReactLoaded(); 175 | }, 1000); 176 | 177 | createPanelIfReactLoaded(); 178 | -------------------------------------------------------------------------------- /shells/browser/shared/src/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | /* global chrome */ 9 | 10 | export function createViewElementSource(bridge: Bridge, store: Store) { 11 | return function viewElementSource(id) { 12 | const rendererID = store.getRendererIDForElement(id); 13 | if (rendererID != null) { 14 | // Ask the renderer interface to determine the component function, 15 | // and store it as a global variable on the window 16 | bridge.send('viewElementSource', { id, rendererID }); 17 | 18 | setTimeout(() => { 19 | // Ask Chrome to display the location of the component function, 20 | // assuming the renderer found one. 21 | chrome.devtools.inspectedWindow.eval(` 22 | if (window.$type != null) { 23 | inspect(window.$type); 24 | } 25 | `); 26 | }, 100); 27 | } 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /shells/browser/shared/view/App.jsx: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | import React, { useEffect, useState } from 'react'; 3 | 4 | const port = chrome.runtime.connect({ name: 'test' }); 5 | 6 | const App = () => { 7 | const [tree, setTree] = useState(); 8 | // const [history, setHistory] = useState([]); 9 | // const [count, setCount] = useState(1); 10 | 11 | // function is receiving fibernode state changes from backend and is saving that data to tree hook 12 | useEffect(() => { 13 | port.postMessage({ 14 | name: 'connect', 15 | tabID: chrome.devtools.inspectedWindow.tabId 16 | }); 17 | 18 | port.onMessage.addListener(message => { 19 | if (message.length === 3) { 20 | setTree(message); 21 | } 22 | }); 23 | }, []); 24 | return
; 25 | }; 26 | 27 | export default App; 28 | -------------------------------------------------------------------------------- /shells/browser/shared/view/index.js: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import React from 'react'; 4 | import { render } from 'react-dom'; 5 | 6 | import App from './App.jsx'; 7 | import styles from './styles.scss'; 8 | 9 | /** 10 | * Copyright (c) Facebook, Inc. and its affiliates. 11 | * 12 | * This source code is licensed under the MIT license found in the 13 | * LICENSE file in the root directory of this source tree. 14 | */ 15 | 16 | // Portal target container. 17 | window.container = document.getElementById('container'); 18 | 19 | let hasInjectedStyles = false; 20 | 21 | // DevTools styles are injected into the top-level document head (where the main React app is rendered). 22 | // This method copies those styles to the child window where each panel (e.g. Elements, Profiler) is portaled. 23 | window.injectStyles = getLinkTags => { 24 | if (!hasInjectedStyles) { 25 | hasInjectedStyles = true; 26 | 27 | const linkTags = getLinkTags(); 28 | 29 | for (const linkTag of linkTags) { 30 | document.head.appendChild(linkTag); 31 | } 32 | } 33 | }; 34 | 35 | render(, document.getElementById('root')); 36 | -------------------------------------------------------------------------------- /shells/browser/shared/view/styles.scss: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | //*********************************** 4 | //********* VARIABLES *********** 5 | //*********************************** 6 | 7 | //*********************************** 8 | //********** GENERAL ************ 9 | //*********************************** 10 | 11 | .button { 12 | border-radius: 5px; 13 | border-color: gray; 14 | border-width: 3px; 15 | } 16 | .snapshot-nav button { 17 | margin-right: 12px; 18 | } 19 | 20 | #container { 21 | padding: 10px 0 0 10px; 22 | } 23 | .navigation { 24 | padding: 0; 25 | overflow: auto; 26 | } 27 | .navigation .tabs { 28 | margin: 0 0.75em; 29 | } 30 | button.button.is-small.is-link { 31 | margin-left: 10px; 32 | } 33 | .column { 34 | border-right: 1px solid #e4e0e0; 35 | height: 90vh; 36 | } 37 | 38 | #timeline-mini-col { 39 | border-right: none !important; 40 | } 41 | 42 | #snapshot-info-col { 43 | border-right: none !important; 44 | } 45 | 46 | .scrollable { 47 | overflow: scroll; 48 | } 49 | 50 | //*********************************** 51 | //********** STORE ************ 52 | //*********************************** 53 | 54 | .slider-textcolor { 55 | color: #060606; 56 | font-weight: bold; 57 | margin: 20px 0; 58 | } 59 | 60 | //***** MENU ***** 61 | .type { 62 | background: lightblue; 63 | } 64 | 65 | .menu-list { 66 | width: 100%; 67 | font-size: 10px; 68 | } 69 | .menu-list a { 70 | word-break: break-all; 71 | } 72 | .records { 73 | width: 100%; 74 | margin-left: 2em; 75 | } 76 | 77 | .records:first-child { 78 | margin-left: 0em; 79 | border-bottom: 1px grey solid; 80 | } 81 | 82 | //***** STORE DISPLAY ***** 83 | .display-box { 84 | border-radius: 5px; 85 | width: 100%; 86 | font-size: 10px; 87 | } 88 | 89 | .key { 90 | font-weight: bold; 91 | word-wrap: break-word; 92 | } 93 | 94 | .logo img { 95 | height: 30px; 96 | } 97 | 98 | .value { 99 | word-wrap: break-word; 100 | } 101 | 102 | .snapshots { 103 | padding: 0 10px; 104 | } 105 | .snapshot-nav { 106 | margin-top: 30px; 107 | } 108 | .input-range { 109 | margin: 35px 0px; 110 | } 111 | .tabs.is-toggle li.is-active a { 112 | background-color: #00d1b2; 113 | border-color: #00d1b2; 114 | color: #fff; 115 | z-index: 1; 116 | } 117 | .input-range__slider { 118 | appearance: none; 119 | background: #00d1b2; 120 | border: 1px solid #0bc3a8; 121 | border-radius: 100%; 122 | cursor: pointer; 123 | display: block; 124 | height: 1rem; 125 | margin-left: -0.5rem; 126 | margin-top: -0.65rem; 127 | outline: none; 128 | position: absolute; 129 | top: 50%; 130 | transition: transform 0.3s ease-out, box-shadow 0.3s ease-out; 131 | width: 1rem; 132 | } 133 | .input-range__slider:active { 134 | transform: scale(1.3); 135 | } 136 | .input-range__slider:focus { 137 | box-shadow: 0 0 0 5px rgba(63, 81, 181, 0.2); 138 | } 139 | .input-range--disabled .input-range__slider { 140 | background: #cccccc; 141 | border: 1px solid #cccccc; 142 | box-shadow: none; 143 | transform: none; 144 | } 145 | 146 | .input-range__slider-container { 147 | transition: left 0.3s ease-out; 148 | } 149 | 150 | .input-range__label { 151 | color: #aaaaaa; 152 | font-family: "Helvetica Neue", san-serif; 153 | font-size: 0.8rem; 154 | transform: translateZ(0); 155 | white-space: nowrap; 156 | } 157 | 158 | .input-range__label--min, 159 | .input-range__label--max { 160 | bottom: -1.4rem; 161 | position: absolute; 162 | } 163 | 164 | .input-range__label--min { 165 | left: 0; 166 | } 167 | 168 | .input-range__label--max { 169 | right: 0; 170 | } 171 | 172 | .input-range__label--value { 173 | position: absolute; 174 | top: -1.8rem; 175 | } 176 | 177 | .input-range__label-container { 178 | left: -50%; 179 | position: relative; 180 | } 181 | .input-range__label--max .input-range__label-container { 182 | left: 50%; 183 | } 184 | 185 | .input-range__track { 186 | background: #eeeeee; 187 | border-radius: 0.3rem; 188 | cursor: pointer; 189 | display: block; 190 | height: 0.3rem; 191 | position: relative; 192 | transition: left 0.3s ease-out, width 0.3s ease-out; 193 | } 194 | .input-range--disabled .input-range__track { 195 | background: #eeeeee; 196 | } 197 | 198 | .input-range__track--background { 199 | left: 0; 200 | margin-top: -0.15rem; 201 | position: absolute; 202 | right: 0; 203 | top: 50%; 204 | } 205 | 206 | .input-range__track--active { 207 | background: #3f51b5; 208 | } 209 | 210 | .input-range { 211 | height: 1rem; 212 | position: relative; 213 | width: 100%; 214 | } 215 | .type { 216 | background: lightblue; 217 | } 218 | 219 | .menu-list { 220 | width: 100%; 221 | } 222 | 223 | .record-line { 224 | border-bottom: 1px grey solid; 225 | } 226 | 227 | .records { 228 | width: 100%; 229 | margin-left: 2em; 230 | } 231 | 232 | .records:first-child { 233 | margin-left: 0em; 234 | } 235 | 236 | .snapshots .column { 237 | height: auto; 238 | } 239 | 240 | @media screen and (max-width: 768px) { 241 | .column { 242 | height: auto; 243 | } 244 | .column.is-half-mobile { 245 | padding-top: 0; 246 | } 247 | .snapshots { 248 | .column { 249 | align-items: center; 250 | height: auto; 251 | } 252 | } 253 | .snapshot-nav { 254 | margin-top: 0; 255 | width: 100%; 256 | } 257 | .input-range { 258 | width: 70%; 259 | margin: 25px 0; 260 | } 261 | } 262 | 263 | //768 - 1020 beside snapshots get rid of columns and multi-line// added 264 | 265 | //issue now between 712ish 988 266 | // @media screen and (min-width: 769px) and (max-width: 1020px) { 267 | // .column { 268 | // height: auto; 269 | // } 270 | // .column.is-half-mobile { 271 | // padding-top: 0; 272 | // } 273 | // .snapshots { 274 | // .column { 275 | // align-items: center; 276 | // height: auto; 277 | // } 278 | // } 279 | // .snapshot-nav { 280 | // margin-top: 0; 281 | // width: 100%; 282 | // } 283 | // .input-range { 284 | // width: 70%; 285 | // margin: 25px 0; 286 | // } 287 | // } 288 | -------------------------------------------------------------------------------- /shells/browser/shared/webpack.backend.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const { resolve } = require('path'); 9 | const { DefinePlugin } = require('webpack'); 10 | const { 11 | getGitHubIssuesURL, 12 | getGitHubURL, 13 | getInternalDevToolsFeedbackGroup, 14 | getVersionString 15 | } = require('../../utils'); 16 | 17 | const NODE_ENV = process.env.NODE_ENV; 18 | if (!NODE_ENV) { 19 | console.error('NODE_ENV not set'); 20 | process.exit(1); 21 | } 22 | 23 | const __DEV__ = NODE_ENV === 'development'; 24 | 25 | const GITHUB_URL = getGitHubURL(); 26 | const DEVTOOLS_VERSION = getVersionString(); 27 | const GITHUB_ISSUES_URL = getGitHubIssuesURL(); 28 | const DEVTOOLS_FEEDBACK_GROUP = getInternalDevToolsFeedbackGroup(); 29 | 30 | module.exports = { 31 | mode: __DEV__ ? 'development' : 'production', 32 | devtool: __DEV__ ? 'cheap-module-eval-source-map' : false, 33 | entry: { 34 | backend: './src/backend.js' 35 | }, 36 | output: { 37 | path: __dirname + '/build', 38 | filename: '[name].js' 39 | }, 40 | resolve: { 41 | alias: { 42 | src: resolve(__dirname, '../../../src') 43 | } 44 | }, 45 | plugins: [ 46 | new DefinePlugin({ 47 | __DEV__: true, 48 | 'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`, 49 | 'process.env.GITHUB_URL': `"${GITHUB_URL}"`, 50 | 'process.env.GITHUB_ISSUES_URL': `"${GITHUB_ISSUES_URL}"`, 51 | 'process.env.DEVTOOLS_FEEDBACK_GROUP': `"${DEVTOOLS_FEEDBACK_GROUP}"` 52 | }) 53 | ], 54 | module: { 55 | rules: [ 56 | { 57 | test: /\.js$/, 58 | loader: 'babel-loader', 59 | options: { 60 | configFile: resolve(__dirname, '../../../babel.config.js') 61 | } 62 | }, 63 | { 64 | test: /.(css|scss)$/, 65 | use: ['style-loader', 'css-loader', 'sass-loader'] 66 | } 67 | ] 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /shells/browser/shared/webpack.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const { resolve } = require('path'); 9 | const { DefinePlugin } = require('webpack'); 10 | const { 11 | getGitHubIssuesURL, 12 | getGitHubURL, 13 | getInternalDevToolsFeedbackGroup, 14 | getVersionString 15 | } = require('../../utils'); 16 | 17 | const NODE_ENV = process.env.NODE_ENV; 18 | if (!NODE_ENV) { 19 | console.error('NODE_ENV not set'); 20 | process.exit(1); 21 | } 22 | 23 | const __DEV__ = NODE_ENV === 'development'; 24 | 25 | const GITHUB_URL = getGitHubURL(); 26 | const DEVTOOLS_VERSION = getVersionString(); 27 | const GITHUB_ISSUES_URL = getGitHubIssuesURL(); 28 | const DEVTOOLS_FEEDBACK_GROUP = getInternalDevToolsFeedbackGroup(); 29 | 30 | module.exports = { 31 | mode: __DEV__ ? 'development' : 'production', 32 | devtool: __DEV__ ? 'cheap-module-eval-source-map' : false, 33 | entry: { 34 | background: './src/background.js', 35 | contentScript: './src/contentScript.js', 36 | injectGlobalHook: './src/injectGlobalHook.js', 37 | index: './view/index.js', 38 | main: './src/main.js' 39 | }, 40 | output: { 41 | path: __dirname + '/build', 42 | filename: '[name].js' 43 | }, 44 | resolve: { 45 | alias: { 46 | src: resolve(__dirname, '../../../src') 47 | } 48 | }, 49 | plugins: [ 50 | new DefinePlugin({ 51 | __DEV__: false, 52 | 'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`, 53 | 'process.env.GITHUB_URL': `"${GITHUB_URL}"`, 54 | 'process.env.GITHUB_ISSUES_URL': `"${GITHUB_ISSUES_URL}"`, 55 | 'process.env.DEVTOOLS_FEEDBACK_GROUP': `"${DEVTOOLS_FEEDBACK_GROUP}"` 56 | }) 57 | ], 58 | module: { 59 | rules: [ 60 | { 61 | test: /.jsx?$/, 62 | exclude: /node_modules/, 63 | loader: 'babel-loader', 64 | options: { 65 | configFile: resolve(__dirname, '../../../babel.config.js') 66 | } 67 | }, 68 | { 69 | test: /.(css|scss)$/, 70 | use: ['style-loader', 'css-loader', 'sass-loader'] 71 | } 72 | ] 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /shells/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const { execSync } = require('child_process'); 9 | const { readFileSync, existsSync } = require('fs'); 10 | const { resolve } = require('path'); 11 | 12 | function getCommit() { 13 | if (existsSync(resolve(__dirname, '../.git'))) { 14 | return execSync('git show -s --format=%h') 15 | .toString() 16 | .trim(); 17 | } 18 | return execSync('hg id -i') 19 | .toString() 20 | .trim(); 21 | } 22 | 23 | function getGitHubURL() { 24 | return 'https://github.com/relayjs/relay-devtools'; 25 | } 26 | 27 | function getGitHubIssuesURL() { 28 | return 'https://github.com/relayjs/relay-devtools/issues/new'; 29 | } 30 | 31 | function getInternalDevToolsFeedbackGroup() { 32 | return 'https://fburl.com/ieftwi8l'; 33 | } 34 | 35 | function getVersionString() { 36 | const packageVersion = JSON.parse(readFileSync(resolve(__dirname, '../package.json'))).version; 37 | 38 | const commit = getCommit(); 39 | 40 | return `${packageVersion}-${commit}`; 41 | } 42 | 43 | module.exports = { 44 | getCommit, 45 | getGitHubIssuesURL, 46 | getGitHubURL, 47 | getInternalDevToolsFeedbackGroup, 48 | getVersionString 49 | }; 50 | -------------------------------------------------------------------------------- /src/backend/EnvironmentWrapper.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow 8 | */ 9 | 10 | import type { DevToolsHook, RelayEnvironment, EnvironmentWrapper } from './types'; 11 | 12 | export function attach( 13 | hook: DevToolsHook, 14 | rendererID: number, 15 | environment: RelayEnvironment, 16 | global: Object 17 | ): EnvironmentWrapper { 18 | let pendingEventsQueue = []; 19 | const store = environment.getStore(); 20 | 21 | const originalLog = environment.__log; 22 | environment.__log = event => { 23 | originalLog(event); 24 | // TODO(damassart): Make this a modular function 25 | if (pendingEventsQueue !== null) { 26 | pendingEventsQueue.push(event); 27 | } else { 28 | hook.emit('environment.event', { 29 | id: rendererID, 30 | data: event, 31 | eventType: 'environment' 32 | }); 33 | } 34 | }; 35 | 36 | const storeOriginalLog = store.__log; 37 | // TODO(damassart): Make this cleaner 38 | store.__log = event => { 39 | if (storeOriginalLog !== null) { 40 | storeOriginalLog(event); 41 | } 42 | switch (event.name) { 43 | case 'store.gc': 44 | // references is a Set, but we can't serialize Sets, 45 | // so we convert references to an Array 46 | event.references = Array.from(event.references); 47 | hook.emit('environment.event', { 48 | id: rendererID, 49 | data: event, 50 | eventType: 'store' 51 | }); 52 | break; 53 | case 'store.notify.complete': 54 | event.invalidatedRecordIDs = Array.from(event.invalidatedRecordIDs); 55 | hook.emit('environment.event', { 56 | id: rendererID, 57 | data: event, 58 | eventType: 'store' 59 | }); 60 | break; 61 | default: 62 | hook.emit('environment.event', { 63 | id: rendererID, 64 | data: event, 65 | eventType: 'store' 66 | }); 67 | break; 68 | } 69 | }; 70 | 71 | function cleanup() { 72 | // We don't patch any methods so there is no cleanup. 73 | environment.__log = originalLog; 74 | store.__log = storeOriginalLog; 75 | } 76 | 77 | function sendStoreRecords() { 78 | const records = store.getSource().toJSON(); 79 | hook.emit('environment.store', { 80 | name: 'refresh.store', 81 | id: rendererID, 82 | records 83 | }); 84 | } 85 | 86 | function flushInitialOperations() { 87 | // TODO(damassart): Make this a modular function 88 | if (pendingEventsQueue != null) { 89 | pendingEventsQueue.forEach(pendingEvent => { 90 | hook.emit('environment.event', { 91 | id: rendererID, 92 | envName: environment.configName, 93 | data: pendingEvent, 94 | eventType: 'environment' 95 | }); 96 | }); 97 | pendingEventsQueue = null; 98 | } 99 | this.sendStoreRecords(); 100 | } 101 | 102 | return { 103 | cleanup, 104 | sendStoreRecords, 105 | flushInitialOperations 106 | }; 107 | } 108 | -------------------------------------------------------------------------------- /src/backend/agent.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow 8 | */ 9 | 10 | import EventEmitter from 'events'; 11 | import type { BackendBridge } from 'src/bridge'; 12 | 13 | import type { EnvironmentID, EnvironmentWrapper } from './types'; 14 | 15 | export default class Agent extends EventEmitter<{| 16 | shutdown: [], 17 | refreshStore: [] 18 | |}> { 19 | _bridge: BackendBridge; 20 | _recordChangeDescriptions: boolean = false; 21 | _environmentWrappers: { 22 | [key: EnvironmentID]: EnvironmentWrapper 23 | } = {}; 24 | 25 | constructor(bridge: BackendBridge) { 26 | super(); 27 | 28 | this._bridge = bridge; 29 | 30 | bridge.addListener('shutdown', this.shutdown); 31 | bridge.addListener('refreshStore', this.refreshStore); 32 | } 33 | 34 | get environmentWrappers(): { 35 | [key: EnvironmentID]: EnvironmentWrapper 36 | } { 37 | return this._environmentWrappers; 38 | } 39 | 40 | shutdown = () => { 41 | // Clean up the overlay if visible, and associated events. 42 | this.emit('shutdown'); 43 | }; 44 | 45 | refreshStore = (id: EnvironmentID) => { 46 | const wrapper = this._environmentWrappers[id]; 47 | wrapper && wrapper.sendStoreRecords(); 48 | }; 49 | 50 | onEnvironmentInitialized = (data: mixed) => { 51 | this._bridge.send('environmentInitialized', [data]); 52 | }; 53 | 54 | setEnvironmentWrapper = (id: number, environmentWrapper: EnvironmentWrapper) => { 55 | this._environmentWrappers[id] = environmentWrapper; 56 | }; 57 | 58 | onStoreData = (data: mixed) => { 59 | this._bridge.send('storeRecords', [data]); 60 | }; 61 | 62 | onEnvironmentEvent = (data: mixed) => { 63 | this._bridge.send('events', [data]); 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /src/backend/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow 8 | */ 9 | 10 | import type { DevToolsHook, RelayEnvironment, EnvironmentWrapper } from './types'; 11 | import type Agent from './agent'; 12 | 13 | import { attach } from './EnvironmentWrapper'; 14 | 15 | export function initBackend(hook: DevToolsHook, agent: Agent, global: Object): () => void { 16 | const subs = [ 17 | hook.sub('environment.event', data => { 18 | agent.onEnvironmentEvent(data); 19 | }), 20 | hook.sub('environment.store', data => { 21 | agent.onStoreData(data); 22 | }), 23 | hook.sub( 24 | 'environment-attached', 25 | ({ 26 | id, 27 | environment, 28 | environmentWrapper 29 | }: { 30 | id: number, 31 | environment: RelayEnvironment, 32 | environmentWrapper: EnvironmentWrapper 33 | }) => { 34 | agent.setEnvironmentWrapper(id, environmentWrapper); 35 | agent.onEnvironmentInitialized({ 36 | id: id, 37 | environmentName: environment.configName 38 | }); 39 | // Now that the Store and the renderer interface are connected, 40 | // it's time to flush the pending operation codes to the frontend. 41 | environmentWrapper.flushInitialOperations(); 42 | } 43 | ) 44 | ]; 45 | 46 | const attachEnvironment = (id: number, environment: RelayEnvironment) => { 47 | let environmentWrapper = hook.environmentWrappers.get(id); 48 | 49 | // Inject any not-yet-injected renderers (if we didn't reload-and-profile) 50 | if (!environmentWrapper) { 51 | environmentWrapper = attach(hook, id, environment, global); 52 | hook.environmentWrappers.set(id, environmentWrapper); 53 | } 54 | 55 | // Notify the DevTools frontend about new renderers. 56 | hook.emit('environment-attached', { 57 | id, 58 | environment, 59 | environmentWrapper 60 | }); 61 | }; 62 | 63 | // Connect renderers that have already injected themselves. 64 | hook.environments.forEach((environment, id) => { 65 | attachEnvironment(id, environment); 66 | }); 67 | 68 | // Connect any new renderers that injected themselves. 69 | subs.push( 70 | hook.sub( 71 | 'environment', 72 | ({ id, environment }: { id: number, environment: RelayEnvironment }) => { 73 | attachEnvironment(id, environment); 74 | } 75 | ) 76 | ); 77 | 78 | return () => { 79 | subs.forEach(fn => fn()); 80 | }; 81 | } 82 | -------------------------------------------------------------------------------- /src/backend/types.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow 8 | */ 9 | 10 | export type EnvironmentID = number; 11 | 12 | export type RelayRecordSource = { 13 | getRecordIDs: () => string, 14 | get: (id: string) => any, 15 | toJSON: () => any 16 | }; 17 | 18 | export type RelayStore = { 19 | getSource: () => RelayRecordSource, 20 | __log: (event: Object) => void 21 | }; 22 | 23 | export type RelayEnvironment = { 24 | execute: (options: any) => any, 25 | configName: ?string, 26 | getStore: () => RelayStore, 27 | __log: (event: Object) => void 28 | }; 29 | 30 | export type EnvironmentWrapper = { 31 | flushInitialOperations: () => void, 32 | sendStoreRecords: () => void, 33 | cleanup: () => void 34 | }; 35 | 36 | export type Handler = (data: any) => void; 37 | 38 | export type DevToolsHook = { 39 | registerEnvironment: (env: RelayEnvironment) => number | null, 40 | // listeners: { [key: string]: Array }, 41 | environmentWrappers: Map, 42 | environments: Map, 43 | 44 | emit: (event: string, data: any) => void, 45 | on: (event: string, handler: Handler) => void, 46 | off: (event: string, handler: Handler) => void, 47 | // reactDevtoolsAgent?: ?Object, 48 | sub: (event: string, handler: Handler) => () => void 49 | }; 50 | -------------------------------------------------------------------------------- /src/backend/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow 8 | */ 9 | 10 | export function copyWithSet( 11 | obj: Object | Array, 12 | path: Array, 13 | value: any, 14 | index: number = 0 15 | ): Object | Array { 16 | console.log('[utils] copyWithSet()', obj, path, index, value); 17 | if (index >= path.length) { 18 | return value; 19 | } 20 | const key = parseInt(path[index]); 21 | const updated = Array.isArray(obj) ? obj.slice() : { ...obj }; 22 | updated[key] = copyWithSet(obj[key], path, value, index + 1); 23 | return updated; 24 | } 25 | -------------------------------------------------------------------------------- /src/bridge.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow 8 | */ 9 | 10 | import EventEmitter from 'events'; 11 | 12 | import type { EnvironmentInfo, EventData, StoreData, Wall } from './types'; 13 | 14 | const BATCH_DURATION = 100; 15 | 16 | type Message = {| 17 | event: string, 18 | payload: any 19 | |}; 20 | 21 | type BackendEvents = {| 22 | events: [Array], 23 | shutdown: [], 24 | environmentInitialized: [Array], 25 | storeRecords: [Array] 26 | |}; 27 | 28 | type FrontendEvents = {| 29 | refreshStore: [number] 30 | |}; 31 | class Bridge extends EventEmitter<{| 32 | ...IncomingEvents, 33 | ...OutgoingEvents 34 | |}> { 35 | _isShutdown: boolean = false; 36 | _messageQueue: Array = []; 37 | _timeoutID: TimeoutID | null = null; 38 | _wall: Wall; 39 | _wallUnlisten: Function | null = null; 40 | 41 | constructor(wall: Wall) { 42 | super(); 43 | 44 | this._wall = wall; 45 | 46 | this._wallUnlisten = 47 | wall.listen((message: Message) => { 48 | (this: any).emit(message.event, message.payload); 49 | }) || null; 50 | } 51 | 52 | send(event: string, payload: any, transferable?: Array) { 53 | if (this._isShutdown) { 54 | console.warn(`Cannot send message "${event}" through a Bridge that has been shutdown.`); 55 | return; 56 | } 57 | 58 | // When we receive a message: 59 | // - we add it to our queue of messages to be sent 60 | // - if there hasn't been a message recently, we set a timer for 0 ms in 61 | // the future, allowing all messages created in the same tick to be sent 62 | // together 63 | // - if there *has* been a message flushed in the last BATCH_DURATION ms 64 | // (or we're waiting for our setTimeout-0 to fire), then _timeoutID will 65 | // be set, and we'll simply add to the queue and wait for that 66 | this._messageQueue.push(event, payload, transferable); 67 | if (!this._timeoutID) { 68 | this._timeoutID = setTimeout(this._flush, 0); 69 | } 70 | } 71 | 72 | shutdown() { 73 | if (this._isShutdown) { 74 | console.warn('Bridge was already shutdown.'); 75 | return; 76 | } 77 | 78 | // Queue the shutdown outgoing message for subscribers. 79 | this.send('shutdown'); 80 | 81 | // Mark this bridge as destroyed, i.e. disable its public API. 82 | this._isShutdown = true; 83 | 84 | // Disable the API inherited from EventEmitter that can add more listeners and send more messages. 85 | (this: any).addListener = function() {}; 86 | this.emit = function() {}; 87 | // NOTE: There's also EventEmitter API like `on` and `prependListener` that we didn't add to our Flow type of EventEmitter. 88 | 89 | // Unsubscribe this bridge incoming message listeners to be sure, and so they don't have to do that. 90 | this.removeAllListeners(); 91 | 92 | // Stop accepting and emitting incoming messages from the wall. 93 | const wallUnlisten = this._wallUnlisten; 94 | if (wallUnlisten) { 95 | wallUnlisten(); 96 | } 97 | 98 | // Synchronously flush all queued outgoing messages. 99 | // At this step the subscribers' code may run in this call stack. 100 | do { 101 | this._flush(); 102 | } while (this._messageQueue.length); 103 | 104 | // Make sure once again that there is no dangling timer. 105 | clearTimeout(this._timeoutID); 106 | this._timeoutID = null; 107 | } 108 | 109 | _flush = () => { 110 | // This method is used after the bridge is marked as destroyed in shutdown sequence, 111 | // so we do not bail out if the bridge marked as destroyed. 112 | // It is a private method that the bridge ensures is only called at the right times. 113 | 114 | clearTimeout(this._timeoutID); 115 | this._timeoutID = null; 116 | 117 | if (this._messageQueue.length) { 118 | for (let i = 0; i < this._messageQueue.length; i += 3) { 119 | this._wall.send( 120 | this._messageQueue[i], 121 | this._messageQueue[i + 1], 122 | this._messageQueue[i + 2] 123 | ); 124 | } 125 | this._messageQueue.length = 0; 126 | 127 | // Check again for queued messages in BATCH_DURATION ms. This will keep 128 | // flushing in a loop as long as messages continue to be added. Once no 129 | // more are, the timer expires. 130 | this._timeoutID = setTimeout(this._flush, BATCH_DURATION); 131 | } 132 | }; 133 | } 134 | 135 | export type BackendBridge = Bridge; 136 | export type FrontendBridge = Bridge; 137 | 138 | export default Bridge; 139 | -------------------------------------------------------------------------------- /src/devtools/DevTools.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow 8 | */ 9 | 10 | // Reach styles need to come before any component styles. 11 | // This makes overridding the styles simpler. 12 | 13 | import React, { useState, useCallback, useEffect } from 'react'; 14 | import type { FrontendBridge } from 'src/bridge'; 15 | import Store from './store'; 16 | import { BridgeContext, StoreContext } from './context'; 17 | import NetworkDisplayer from './view/NetworkDisplayer'; 18 | import StoreTimeline from './view/StoreTimeline'; 19 | 20 | // export type TabID = 'network' | 'settings' | 'store-inspector'; 21 | export type ViewElementSource = (id: number) => void; 22 | 23 | export type Props = {| 24 | bridge: FrontendBridge, 25 | // defaultTab?: TabID, 26 | // showTabBar?: boolean, 27 | store: Store, 28 | viewElementSourceFunction?: ?ViewElementSource, 29 | viewElementSourceRequiresFileLocation?: boolean, 30 | 31 | // This property is used only by the web extension target. 32 | // The built-in tab UI is hidden in that case, in favor of the browser's own panel tabs. 33 | // This is done to save space within the app. 34 | // Because of this, the extension needs to be able to change which tab is active/rendered. 35 | // overrideTab?: TabID, 36 | 37 | // TODO: Cleanup multi-tabs in webextensions 38 | // To avoid potential multi-root trickiness, the web extension uses portals to render tabs. 39 | // The root app is rendered in the top-level extension window, 40 | // but individual tabs (e.g. Components, Profiling) can be rendered into portals within their browser panels. 41 | rootContainer?: Element, 42 | // networkPortalContainer?: Element, 43 | settingsPortalContainer?: Element, 44 | storeInspectorPortalContainer?: Element 45 | |}; 46 | 47 | const networkTab = { 48 | id: ('network': TabID), 49 | icon: 'network', 50 | label: 'Network', 51 | title: 'Relay Network' 52 | }; 53 | const storeInspectorTab = { 54 | id: ('store-inspector': TabID), 55 | icon: 'store-inspector', 56 | label: 'Store', 57 | title: 'Relay Store' 58 | }; 59 | 60 | const tabs = [networkTab, storeInspectorTab]; 61 | 62 | export default function DevTools({ 63 | bridge, 64 | rootContainer, 65 | networkPortalContainer, 66 | storeInspectorPortalContainer, 67 | settingsPortalContainer, 68 | store, 69 | viewElementSourceFunction, 70 | viewElementSourceRequiresFileLocation = false 71 | }: Props) { 72 | const [environmentIDs, setEnvironmentIDs] = useState(store.getEnvironmentIDs()); 73 | const [currentEnvID, setCurrentEnvID] = useState(environmentIDs[0]); 74 | const [selector, setSelector] = useState('Store'); 75 | 76 | const setEnv = useCallback(() => { 77 | const ids = store.getEnvironmentIDs(); 78 | if (currentEnvID === undefined) { 79 | const firstKey = ids[0]; 80 | setCurrentEnvID(firstKey); 81 | } 82 | setEnvironmentIDs(ids); 83 | }, [store, currentEnvID]); 84 | 85 | useEffect(() => { 86 | setEnv(); 87 | store.addListener('environmentInitialized', setEnv); 88 | return () => { 89 | store.removeListener('environmentInitialized', setEnv); 90 | }; 91 | }, [store, setEnv]); 92 | 93 | function handleTabClick(e, tab) { 94 | setSelector(tab); 95 | } 96 | 97 | const handleChange = useCallback(e => { 98 | setCurrentEnvID(parseInt(e.target.value)); 99 | }, []); 100 | 101 | console.log('currentenvid before render', currentEnvID); 102 | 103 | return ( 104 | 105 | 106 |
107 |
108 | 117 |
118 | 143 |
144 | 145 | 146 | 147 |
148 |
149 |
150 | {currentEnvID && ( 151 | 155 | )} 156 |
157 |
158 | {currentEnvID && } 159 |
160 |
161 |
162 | ); 163 | } 164 | -------------------------------------------------------------------------------- /src/devtools/context.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow 8 | */ 9 | 10 | import { createContext } from 'react'; 11 | 12 | import type { FrontendBridge } from 'src/bridge'; 13 | import type Store from 'store'; 14 | 15 | export const BridgeContext = createContext(((null: any): FrontendBridge)); 16 | BridgeContext.displayName = 'BridgeContext'; 17 | 18 | export const StoreContext = createContext(((null: any): Store)); 19 | StoreContext.displayName = 'StoreContext'; 20 | -------------------------------------------------------------------------------- /src/devtools/store.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow 8 | */ 9 | const __DEBUG__ = true; 10 | 11 | import EventEmitter from 'events'; 12 | import type { FrontendBridge } from 'src/bridge'; 13 | import type { 14 | DataID, 15 | LogEvent, 16 | EventData, 17 | EnvironmentInfo, 18 | StoreData, 19 | StoreRecords, 20 | Record 21 | } from '../types'; 22 | 23 | const debug = (methodName, ...args) => { 24 | if (__DEBUG__) { 25 | console.log( 26 | `%cStore %c${methodName}`, 27 | 'color: green; font-weight: bold;', 28 | 'font-weight: bold;', 29 | ...args 30 | ); 31 | } 32 | }; 33 | 34 | const storeEventNames = [ 35 | 'queryresource.fetch', 36 | 'store.publish', 37 | 'store.restore', 38 | 'store.gc', 39 | 'store.snapshot', 40 | 'store.notify.complete', 41 | 'store.notify.start' 42 | ]; 43 | 44 | /** 45 | * The store is the single source of truth for updates from the backend. 46 | * ContextProviders can subscribe to the Store for specific things they want to provide. 47 | */ 48 | export default class Store extends EventEmitter<{| 49 | collapseNodesByDefault: [], 50 | componentFilters: [], 51 | environmentInitialized: [], 52 | mutated: [], 53 | storeDataReceived: [], 54 | allEventsReceived: [], 55 | recordChangeDescriptions: [], 56 | roots: [] 57 | |}> { 58 | _bridge: FrontendBridge; 59 | 60 | _environmentEventsMap: Map> = new Map(); 61 | _environmentNames: Map = new Map(); 62 | _environmentStoreData: Map = new Map(); 63 | _environmentStoreOptimisticData: Map = new Map(); 64 | _environmentAllEvents: Map> = new Map(); 65 | _recordedRequests: Map> = new Map(); 66 | _isRecording: boolean = false; 67 | _importEnvID: ?number = null; 68 | 69 | constructor(bridge: FrontendBridge) { 70 | super(); 71 | this._bridge = bridge; 72 | bridge.addListener('events', this.onBridgeEvents); 73 | bridge.addListener('shutdown', this.onBridgeShutdown); 74 | bridge.addListener('environmentInitialized', this.onBridgeEnvironmentInit); 75 | bridge.addListener('storeRecords', this.onBridgeStoreSnapshot); 76 | bridge.addListener('mutationComplete', this.setEnvironmentEvents); 77 | bridge.addListener('all', this.setEnvironmentEvents); 78 | } 79 | 80 | getAllEventsArray(): $ReadOnlyArray { 81 | const allEvents = []; 82 | this._environmentAllEvents.forEach((value, _) => allEvents.push(...value)); 83 | return allEvents; 84 | } 85 | 86 | setAllEventsMap(environmentID: number, events: Array) { 87 | this._environmentAllEvents.set(environmentID, events); 88 | this.emit('allEventsReceived'); 89 | } 90 | 91 | getAllEventsMap(): Map> { 92 | return this._environmentAllEvents; 93 | } 94 | 95 | getEvents(environmentID: number): ?$ReadOnlyArray { 96 | return this._environmentAllEvents.get(environmentID); 97 | } 98 | 99 | getAllEnvironmentEvents(): $ReadOnlyArray { 100 | const allEnvironmentEvents = []; 101 | this._environmentEventsMap.forEach((value, _) => allEnvironmentEvents.push(...value)); 102 | return allEnvironmentEvents; 103 | } 104 | 105 | getEnvironmentEvents(environmentID: number): ?$ReadOnlyArray { 106 | return this._environmentEventsMap.get(environmentID); 107 | } 108 | 109 | getEnvironmentIDs(): $ReadOnlyArray { 110 | return Array.from(this._environmentNames.keys()); 111 | } 112 | 113 | getImportEnvID(): ?number { 114 | return this._importEnvID; 115 | } 116 | 117 | setImportEnvID(envID: ?number) { 118 | this._importEnvID = envID; 119 | this.emit('allEventsReceived'); 120 | } 121 | 122 | getEnvironmentName(environmentID: number): ?string { 123 | return this._environmentNames.get(environmentID); 124 | } 125 | 126 | getRecords(environmentID: number): ?StoreRecords { 127 | return this._environmentStoreData.get(environmentID); 128 | } 129 | 130 | getRecordIDs(environmentID: number): ?$ReadOnlyArray { 131 | const storeRecords = this._environmentStoreData.get(environmentID); 132 | return storeRecords ? Object.keys(storeRecords) : null; 133 | } 134 | 135 | removeRecord(environmentID: number, recordID: string) { 136 | const storeRecords = this._environmentStoreData.get(environmentID); 137 | if (storeRecords != null) { 138 | delete storeRecords[recordID]; 139 | } 140 | } 141 | 142 | getAllRecords(): ?$ReadOnlyArray { 143 | return Array.from(this._environmentStoreData.values()); 144 | } 145 | 146 | getOptimisticUpdates(environmentID: number): ?StoreRecords { 147 | return this._environmentStoreOptimisticData.get(environmentID); 148 | } 149 | 150 | mergeRecords(id: number, newRecords: ?StoreRecords) { 151 | if (newRecords == null) { 152 | return; 153 | } 154 | const oldRecords = this._environmentStoreData.get(id); 155 | if (oldRecords == null) { 156 | this._environmentStoreData.set(id, newRecords); 157 | return; 158 | } 159 | const dataIDs = Object.keys(newRecords); 160 | 161 | for (let ii = 0; ii < dataIDs.length; ii++) { 162 | const dataID = dataIDs[ii]; 163 | const oldRecord = oldRecords[dataID]; 164 | const newRecord = newRecords[dataID]; 165 | if (oldRecord && newRecord) { 166 | let updated: Record | null = null; 167 | const keys = Object.keys(newRecord); 168 | for (let iii = 0; iii < keys.length; iii++) { 169 | const key = keys[iii]; 170 | if (updated || oldRecord[key] !== newRecord[key]) { 171 | updated = updated !== null ? updated : { ...oldRecord }; 172 | updated[key] = newRecord[key]; 173 | } 174 | } 175 | updated = updated !== null ? updated : oldRecord; 176 | if (updated !== newRecord) { 177 | oldRecords[dataID] = updated; 178 | } 179 | } else if (oldRecord == null) { 180 | oldRecords[dataID] = newRecord; 181 | } else if (newRecord == null) { 182 | delete oldRecords[dataID]; 183 | } 184 | } 185 | this._environmentStoreData.set(id, oldRecords); 186 | } 187 | 188 | mergeOptimisticRecords(id: number, newRecords: ?StoreRecords) { 189 | if (newRecords == null) { 190 | return; 191 | } 192 | const oldRecords = this._environmentStoreOptimisticData.get(id); 193 | if (oldRecords == null) { 194 | this._environmentStoreOptimisticData.set(id, newRecords); 195 | return; 196 | } 197 | const dataIDs = Object.keys(newRecords); 198 | 199 | for (let ii = 0; ii < dataIDs.length; ii++) { 200 | const dataID = dataIDs[ii]; 201 | const oldRecord = oldRecords[dataID]; 202 | const newRecord = newRecords[dataID]; 203 | if (oldRecord && newRecord) { 204 | let updated: Record | null = null; 205 | const keys = Object.keys(newRecord); 206 | for (let iii = 0; iii < keys.length; iii++) { 207 | const key = keys[iii]; 208 | if (updated || oldRecord[key] !== newRecord[key]) { 209 | updated = updated !== null ? updated : { ...oldRecord }; 210 | updated[key] = newRecord[key]; 211 | } 212 | } 213 | updated = updated !== null ? updated : oldRecord; 214 | if (updated !== newRecord) { 215 | oldRecords[dataID] = updated; 216 | } 217 | } else if (oldRecord == null) { 218 | oldRecords[dataID] = newRecord; 219 | } else if (newRecord == null) { 220 | delete oldRecords[dataID]; 221 | } 222 | } 223 | this._environmentStoreOptimisticData.set(id, oldRecords); 224 | } 225 | 226 | onBridgeStoreSnapshot = (data: Array) => { 227 | for (const { id, records } of data) { 228 | this._environmentStoreData.set(id, records); 229 | this.emit('storeDataReceived'); 230 | } 231 | }; 232 | 233 | setStoreEvents = (id: number, data: LogEvent) => { 234 | switch (data.name) { 235 | case 'store.publish': 236 | this.mergeRecords(id, data.source); 237 | if (data.optimistic) { 238 | this.mergeOptimisticRecords(id, data.source); 239 | } 240 | break; 241 | case 'store.restore': 242 | this.clearOptimisticUpdates(id); 243 | break; 244 | case 'store.gc': 245 | this.garbageCollectRecords(id, data.references); 246 | break; 247 | default: 248 | break; 249 | } 250 | this.emit('storeDataReceived'); 251 | }; 252 | 253 | setEnvironmentEvents = (id: number, data: LogEvent) => { 254 | const arr = this._environmentEventsMap.get(id); 255 | if (arr) { 256 | arr.push(data); 257 | } else { 258 | this._environmentEventsMap.set(id, [data]); 259 | } 260 | this.emit('mutated'); 261 | if (data.name === 'execute.complete') { 262 | this.emit('mutationComplete'); 263 | } 264 | }; 265 | 266 | appendInformationToRequest = (id: number, data: LogEvent) => { 267 | switch (data.name) { 268 | case 'execute.start': 269 | const requestArr = this._recordedRequests.get(id); 270 | if (requestArr) { 271 | requestArr.set(data.transactionID, data); 272 | } else { 273 | const newRequest = new Map(); 274 | newRequest.set(data.transactionID, data); 275 | this._recordedRequests.set(id, newRequest); 276 | } 277 | break; 278 | case 'execute.next': 279 | case 'execute.info': 280 | case 'execute.complete': 281 | case 'execute.error': 282 | case 'execute.unsubscribe': 283 | const requests = this._recordedRequests.get(id); 284 | if (requests) { 285 | const request = requests.get(data.transactionID); 286 | if (request && request.name === 'execute.start') { 287 | data.params = request.params; 288 | data.variables = request.variables; 289 | } 290 | } 291 | break; 292 | default: 293 | break; 294 | } 295 | }; 296 | 297 | startRecording = () => { 298 | this._isRecording = true; 299 | this.clearAllEvents(); 300 | }; 301 | 302 | stopRecording = () => { 303 | this._isRecording = false; 304 | }; 305 | 306 | onBridgeEvents = (events: Array) => { 307 | for (const { id, data, eventType } of events) { 308 | if (this._isRecording) { 309 | const allEvents = this._environmentAllEvents.get(id); 310 | if (allEvents) { 311 | if (data.name === 'store.gc') { 312 | const records = this.getRecords(id); 313 | if (records != null) { 314 | data.gcRecords = {}; 315 | data.references = Object.keys(records) 316 | .filter(recID => recID != null && !data.references.includes(recID)) 317 | .map(recID => { 318 | data.gcRecords[recID] = records[recID]; 319 | return recID; 320 | }); 321 | } 322 | } else if (data.name === 'store.notify.complete') { 323 | const records = this.getRecords(id); 324 | if (records != null) { 325 | data.invalidatedRecords = {}; 326 | data.updatedRecords = {}; 327 | Object.keys(data.updatedRecordIDs).forEach(recID => { 328 | data.updatedRecords[recID] = { ...records[recID] }; 329 | }); 330 | data.invalidatedRecordIDs.forEach( 331 | recID => (data.invalidatedRecords[recID] = { ...records[recID] }) 332 | ); 333 | } 334 | } else if (data.name.startsWith('execute')) { 335 | this.appendInformationToRequest(id, data); 336 | } 337 | allEvents.push(data); 338 | } else { 339 | this._environmentAllEvents.set(id, [data]); 340 | } 341 | this.emit('allEventsReceived'); 342 | } 343 | if (eventType === 'store') { 344 | this.setStoreEvents(id, data); 345 | } else if (eventType === 'environment') { 346 | this.setEnvironmentEvents(id, data); 347 | } 348 | } 349 | }; 350 | 351 | onBridgeEnvironmentInit = (data: Array) => { 352 | for (const { id, environmentName } of data) { 353 | this._environmentNames.set(id, environmentName); 354 | } 355 | this.emit('environmentInitialized'); 356 | }; 357 | 358 | clearOptimisticUpdates = (envID: number) => { 359 | this._environmentStoreOptimisticData.delete(envID); 360 | }; 361 | 362 | garbageCollectRecords = (envID: number, references: $ReadOnlyArray) => { 363 | if (references.length === 0) { 364 | this._environmentStoreData.delete(envID); 365 | } else { 366 | const storeIDs = this.getRecordIDs(envID); 367 | if (storeIDs == null) { 368 | return; 369 | } 370 | for (const dataID of storeIDs) { 371 | if (!references.includes(dataID)) { 372 | this.removeRecord(envID, dataID); 373 | } 374 | } 375 | } 376 | }; 377 | 378 | clearAllEvents = () => { 379 | this._environmentAllEvents.forEach((_, key) => this.clearEvents(key)); 380 | this.emit('allEventsReceived'); 381 | }; 382 | 383 | clearEvents = (environmentID: number) => { 384 | this._environmentAllEvents.delete(environmentID); 385 | }; 386 | 387 | clearAllNetworkEvents = () => { 388 | this._environmentEventsMap.forEach((_, key) => this.clearNetworkEvents(key)); 389 | this.emit('mutated'); 390 | }; 391 | 392 | clearNetworkEvents = (environmentID: number) => { 393 | const completed = new Set(); 394 | let networkEventArray = this._environmentEventsMap.get(environmentID); 395 | if (networkEventArray !== undefined && networkEventArray.length > 0) { 396 | for (const event of networkEventArray) { 397 | if ( 398 | event.name === 'execute.complete' || 399 | event.name === 'execute.error' || 400 | event.name === 'execute.unsubscribe' 401 | ) { 402 | completed.add(event.transactionID); 403 | } 404 | } 405 | networkEventArray = networkEventArray.filter( 406 | event => 407 | storeEventNames.includes(event.name) && 408 | event.transactionID != null && 409 | !completed.has(event.transactionID) 410 | ); 411 | this._environmentEventsMap.set(environmentID, networkEventArray); 412 | this.emit('mutated'); 413 | } 414 | }; 415 | 416 | onBridgeShutdown = () => { 417 | if (__DEBUG__) { 418 | debug('onBridgeShutdown', 'unsubscribing from Bridge'); 419 | } 420 | 421 | this._bridge.removeListener('events', this.onBridgeEvents); 422 | this._bridge.removeListener('shutdown', this.onBridgeShutdown); 423 | this._bridge.removeListener('environmentInitialized', this.onBridgeEnvironmentInit); 424 | this._bridge.removeListener('storeRecords', this.onBridgeStoreSnapshot); 425 | }; 426 | } 427 | -------------------------------------------------------------------------------- /src/devtools/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow 8 | */ 9 | 10 | export function deepCopyFunction(inObject: any) { 11 | if (typeof inObject !== 'object' || inObject === null) { 12 | return inObject; 13 | } 14 | 15 | if (Array.isArray(inObject)) { 16 | const outObject = []; 17 | for (let i = 0; i < inObject.length; i++) { 18 | const value = inObject[i]; 19 | outObject[i] = deepCopyFunction(value); 20 | } 21 | return outObject; 22 | } else if (inObject instanceof Map) { 23 | const outObject = new Map(); 24 | inObject.forEach((val, key) => { 25 | outObject.set(key, deepCopyFunction(val)); 26 | }); 27 | return outObject; 28 | } else { 29 | const outObject = {}; 30 | for (const key in inObject) { 31 | const value = inObject[key]; 32 | if (typeof key === 'string' && key != null) { 33 | outObject[key] = deepCopyFunction(value); 34 | } 35 | } 36 | return outObject; 37 | } 38 | } 39 | 40 | export function debounce(func, wait) { 41 | let timeout = null; 42 | return function() { 43 | const newfunc = () => { 44 | timeout = null; 45 | func.apply(this, arguments); 46 | }; 47 | clearTimeout(timeout); 48 | timeout = setTimeout(newfunc, wait); 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /src/devtools/view/Components/EnvironmentSelector.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback, useEffect } from 'react'; 2 | 3 | function EnvironmentSelector(props) { 4 | const [selectEnv, setSelectEnv] = useState(''); 5 | 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | 13 | export default EnvironmentSelector; 14 | -------------------------------------------------------------------------------- /src/devtools/view/Components/Record.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function Record(props) { 4 | /* Maps through array and recursively calls component if the props[value] is an object, 5 | otherwise, it will store a key/value pair */ 6 | const records = Object.keys(props).map(key => { 7 | return typeof props[key] === 'object' ? ( 8 |
9 | {key}: 10 | 11 |
12 | ) : ( 13 |
14 | {key}: 15 | {JSON.stringify(props[key])} 16 |
17 | ); 18 | }); 19 | 20 | return
{records}
; 21 | } 22 | 23 | export default Record; 24 | -------------------------------------------------------------------------------- /src/devtools/view/Components/SnapshotLinks.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | const SnapshotLinks = ({ timeline, currentEnvID, handleSnapshot }) => { 4 | const [active, setActive] = useState(null); 5 | // Create links with date and label of snapshot; rendered in the left snapshot column using Bulma menu-list. Active state is used to toggle active link. 6 | return ( 7 |
8 | 25 |
26 | ); 27 | }; 28 | 29 | export default SnapshotLinks; 30 | -------------------------------------------------------------------------------- /src/devtools/view/NetworkDisplayer.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from 'react'; 2 | import { StoreContext } from '../context'; 3 | import Record from './Components/Record'; 4 | import { execute } from 'graphql'; 5 | import { debounce } from '../utils'; 6 | 7 | //iterates over each event and joins events based on transactionID and sorts by type 8 | const combineEvents = events => { 9 | const combinedEvents = {}; 10 | const eventTypes = {}; 11 | //join events by transactionID 12 | events.forEach(event => { 13 | const tempObj = {}; 14 | if (event.name === 'execute.start') { 15 | tempObj.request = event.params; 16 | tempObj.variables = event.variables; 17 | } else if (event.name === 'execute.info') { 18 | tempObj.info = event.info; 19 | } else if (event.name === 'execute.next') { 20 | tempObj.response = event.response; 21 | } else if (event.name === 'execute.complete') { 22 | // tempObj.complete = true 23 | } 24 | combinedEvents[event.transactionID] 25 | ? (combinedEvents[event.transactionID] = Object.assign( 26 | combinedEvents[event.transactionID], 27 | tempObj 28 | )) 29 | : (combinedEvents[event.transactionID] = tempObj); 30 | }); 31 | 32 | //sort by type 33 | Object.keys(combinedEvents).forEach(transactionID => { 34 | const op = combinedEvents[transactionID].request.operationKind; 35 | eventTypes[op] 36 | ? (eventTypes[op] = Object.assign(eventTypes[op], { 37 | [transactionID]: combinedEvents[transactionID] 38 | })) 39 | : (eventTypes[op] = { [transactionID]: combinedEvents[transactionID] }); 40 | }); 41 | 42 | return eventTypes; 43 | }; 44 | 45 | //generates a list of elements for the menu and the events listing 46 | const generateElementList = (events, searchResults, selection, handleMenuClick) => { 47 | const eventMenu = []; 48 | const eventsList = []; 49 | 50 | //for each event - add to menu list 51 | for (let type in events) { 52 | //creates an array of menu items for all events belonging to a given type 53 | const typeList = []; 54 | for (let id in events[type]) { 55 | //filter out results based on search input 56 | if (new RegExp(searchResults, 'i').test(JSON.stringify(events[type][id]))) { 57 | typeList.push( 58 |
  • 59 | { 63 | handleMenuClick(e, id); 64 | }} 65 | > 66 | {events[type][id].request.name} 67 | 68 |
  • 69 | ); 70 | //creates an array of elements for all events 71 | eventsList.push( 72 |
    80 | 81 |
    82 | ); 83 | } 84 | } 85 | 86 | //pushes the new type element with child events to the typeList component array 87 | eventMenu.push( 88 |
  • 89 | { 93 | handleMenuClick(e, type); 94 | }} 95 | > 96 | {type} 97 | 98 |
      {typeList}
    99 |
  • 100 | ); 101 | } 102 | return { eventMenu, eventsList }; 103 | }; 104 | 105 | const NetworkDisplayer = ({ currentEnvID }) => { 106 | const [selection, setSelection] = useState(''); 107 | const [events, setEvents] = useState([]); 108 | const [searchResults, setSearchResults] = useState(''); 109 | const store = useContext(StoreContext); 110 | 111 | useEffect(() => { 112 | //on mutation all store events are pulled and processed with events state updated 113 | const onMutated = () => { 114 | setEvents(combineEvents(store._environmentEventsMap.get(currentEnvID) || [])); 115 | }; 116 | store.addListener('mutated', onMutated); 117 | 118 | return () => { 119 | store.removeListener('mutated', onMutated); 120 | }; 121 | }, [store]); 122 | 123 | //handle type menu click events 124 | function handleMenuClick(e, id) { 125 | //set new selection 126 | setSelection(id); 127 | } 128 | 129 | //shows you the entire network 130 | function handleReset(e) { 131 | //remove selection; 132 | setSelection(''); 133 | } 134 | 135 | //updates search results 136 | const debounced = debounce(val => setSearchResults(val), 300); 137 | function handleSearch(e) { 138 | //debounce search 139 | debounced(e.target.value); 140 | } 141 | 142 | //generate menu list and events list 143 | const { eventMenu, eventsList } = generateElementList( 144 | events, 145 | searchResults, 146 | selection, 147 | handleMenuClick 148 | ); 149 | 150 | return ( 151 | 152 |
    153 |

    154 | { 159 | handleSearch(e); 160 | }} 161 | > 162 | 170 | 171 | 172 | 173 |

    174 | 178 |
    179 |
    180 |
    {eventsList}
    181 |
    182 |
    183 | ); 184 | }; 185 | 186 | export default NetworkDisplayer; 187 | -------------------------------------------------------------------------------- /src/devtools/view/StoreDisplayer.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Record from './Components/Record'; 3 | import { debounce } from '../utils'; 4 | 5 | //update record list to current selection 6 | function updateRecords(store, selection) { 7 | if (store) { 8 | if (selection === '') { 9 | return store; 10 | //id selected - filter out everything except selected id 11 | } else if (selection[0] === 'i') { 12 | const id = selection.slice(3); 13 | return Object.keys(store).reduce((newRL, key) => { 14 | if (store[key].__id === id) newRL[key] = store[key]; 15 | return newRL; 16 | }, {}); 17 | //type selected - filter out everything except selected type 18 | } else { 19 | const type = selection.slice(5); 20 | return Object.keys(store).reduce((newRL, key) => { 21 | if (store[key].__typename === type) newRL[key] = store[key]; 22 | return newRL; 23 | }, {}); 24 | } 25 | } 26 | } 27 | 28 | //generate list of menu elements 29 | function generateComponentsList(store, searchResults, recordsList, selection, handleMenuClick) { 30 | //create menu list of all types 31 | const menuList = {}; 32 | const typeList = []; 33 | 34 | for (let id in store) { 35 | const record = store[id]; 36 | menuList[record.__typename] 37 | ? menuList[record.__typename].push(record.__id) 38 | : (menuList[record.__typename] = [record.__id]); 39 | } 40 | //loop through each type and generate menu item 41 | 42 | for (let type in menuList) { 43 | //creates an array of elements for all ids belonging to a given type 44 | 45 | const idList = menuList[type] 46 | .filter(id => new RegExp(searchResults, 'i').test(JSON.stringify(recordsList[id]))) 47 | .map(id => { 48 | return ( 49 |
  • 50 | { 54 | handleMenuClick('id-' + id); 55 | }} 56 | > 57 | {id} 58 | 59 |
  • 60 | ); 61 | }); 62 | //pushes the new type element with child ids to the typeList component array 63 | if (idList.length !== 0) { 64 | typeList.push( 65 |
  • 66 | { 70 | handleMenuClick('type-' + type); 71 | }} 72 | > 73 | {type} 74 | 75 |
      {idList}
    76 |
  • 77 | ); 78 | } 79 | } 80 | return typeList; 81 | } 82 | 83 | const StoreDisplayer = ({ store }) => { 84 | const [recordsList, setRecordsList] = useState({}); 85 | const [selection, setSelection] = useState(''); 86 | const [searchResults, setSearchResults] = useState(''); 87 | 88 | React.useEffect(() => { 89 | //initialize store 90 | setRecordsList(store); 91 | }, [store]); 92 | 93 | //handle menu click events 94 | function handleMenuClick(selection) { 95 | //set new selection 96 | setSelection(selection); 97 | //update display with current selection 98 | setRecordsList(updateRecords(store, selection)); 99 | } 100 | 101 | //shows you the entire store 102 | function handleReset(e) { 103 | //remove selection 104 | setSelection(''); 105 | //reset back to original store 106 | setRecordsList(store); 107 | } 108 | 109 | //updates search results 110 | const debounced = debounce(val => { 111 | setSelection(''); 112 | setRecordsList(store); 113 | setSearchResults(val); 114 | }, 300); 115 | function handleSearch(e) { 116 | //debounce search 117 | debounced(e.target.value); 118 | } 119 | //generates the menu element list 120 | 121 | //verify recordsList is not undefined and then generate list of components 122 | const typeList = 123 | recordsList === undefined 124 | ? [] 125 | : generateComponentsList(store, searchResults, recordsList, selection, handleMenuClick); 126 | 127 | return ( 128 | 129 |
    130 |

    131 | { 136 | handleSearch(e); 137 | }} 138 | > 139 | 147 | 148 | 149 | 150 |

    151 | 155 |
    156 |
    157 |
    158 | 159 |
    160 |
    161 |
    162 | ); 163 | }; 164 | 165 | export default StoreDisplayer; 166 | -------------------------------------------------------------------------------- /src/devtools/view/StoreTimeline.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext, useEffect } from 'react'; 2 | import InputRange from 'react-input-range'; 3 | import { BridgeContext, StoreContext } from '../context'; 4 | import StoreDisplayer from './StoreDisplayer'; 5 | import SnapshotLinks from './Components/SnapshotLinks'; 6 | 7 | const StoreTimeline = ({ currentEnvID }) => { 8 | const store = useContext(StoreContext); 9 | const bridge = useContext(BridgeContext); 10 | const [snapshotIndex, setSnapshotIndex] = useState(0); 11 | const [timelineLabel, setTimelineLabel] = useState(''); 12 | const [liveStore, setLiveStore] = useState({}); 13 | // Each envId has an array of orbject built up for loading snapshots via the handleClick 14 | const [timeline, setTimeline] = useState({ 15 | [currentEnvID]: [ 16 | { 17 | label: 'at startup', 18 | date: new Date(), 19 | storage: liveStore 20 | } 21 | ] 22 | }); 23 | 24 | // build snapshot object and insert into timeline 25 | const handleClick = e => { 26 | e.preventDefault(); 27 | const timelineInsert = {}; 28 | const timeStamp = new Date(); 29 | timelineInsert.label = timelineLabel; 30 | timelineInsert.date = timeStamp; 31 | timelineInsert.storage = liveStore; 32 | const newTimeline = timeline[currentEnvID].concat([timelineInsert]); 33 | setTimeline({ ...timeline, [currentEnvID]: newTimeline }); 34 | setTimelineLabel(''); 35 | setSnapshotIndex(newTimeline.length); 36 | }; 37 | 38 | const handleSnapshot = index => { 39 | setSnapshotIndex(index); 40 | }; 41 | 42 | const updateStoreHelper = storeObj => { 43 | setLiveStore(storeObj); 44 | }; 45 | 46 | // triggering refresh of store on completed mutation 47 | React.useEffect(() => { 48 | const refreshLiveStore = () => { 49 | bridge.send('refreshStore', currentEnvID); 50 | }; 51 | const refreshEvents = () => { 52 | const allRecords = store.getRecords(currentEnvID); 53 | updateStoreHelper(allRecords); 54 | }; 55 | 56 | store.addListener('storeDataReceived', refreshEvents); 57 | store.addListener('allEventsReceived', refreshEvents); 58 | store.addListener('mutationComplete', refreshLiveStore); 59 | 60 | return () => { 61 | store.removeListener('mutationComplete', refreshLiveStore); 62 | store.removeListener('storeDataReceived', refreshEvents); 63 | store.removeListener('allEventsReceived', refreshEvents); 64 | }; 65 | }, [store]); 66 | 67 | React.useEffect(() => { 68 | const allRecords = store.getRecords(currentEnvID); 69 | setLiveStore(allRecords); 70 | 71 | if (!timeline[currentEnvID]) { 72 | const newTimeline = { 73 | ...timeline, 74 | [currentEnvID]: [ 75 | { 76 | label: 'current', 77 | date: new Date(), 78 | storage: allRecords 79 | } 80 | ] 81 | }; 82 | setTimeline(newTimeline); 83 | setSnapshotIndex(1); 84 | } else { 85 | setSnapshotIndex(timeline[currentEnvID].length); 86 | } 87 | }, [currentEnvID]); 88 | 89 | console.log( 90 | 'showing livestore', 91 | !timeline[currentEnvID] || 92 | !timeline[currentEnvID][snapshotIndex] || 93 | snapshotIndex === timeline[currentEnvID].length 94 | ); 95 | 96 | return ( 97 | 98 |
    99 |
    100 |
    101 | setTimelineLabel(e.target.value)} 106 | placeholder="take a store snapshot" 107 | > 108 | 111 |
    112 |
    113 |
    114 |
    118 | setSnapshotIndex(value)} 123 | /> 124 |
    125 | 135 | 141 | 152 |
    153 |
    154 |
    158 | {timeline[currentEnvID] && ( 159 | 164 | )} 165 |
    166 |
    167 |
    168 | 177 |
    178 | ); 179 | }; 180 | 181 | export default StoreTimeline; 182 | -------------------------------------------------------------------------------- /src/hook.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow 8 | */ 9 | 10 | /** 11 | * Install the hook on window, which is an event emitter. 12 | * Note because Chrome content scripts cannot directly modify the window object, 13 | * we are evaling this function by inserting a script tag. 14 | * That's why we have to inline the whole event emitter implementation here. 15 | */ 16 | 17 | import type { DevToolsHook } from 'src/backend/types'; 18 | 19 | declare var window: any; 20 | 21 | export function installHook(target: any): DevToolsHook | null { 22 | if (target.hasOwnProperty('__RELAY_DEVTOOLS_HOOK__')) { 23 | return null; 24 | } 25 | const listeners = {}; 26 | const environments = new Map(); 27 | 28 | let uidCounter = 0; 29 | 30 | function registerEnvironment(environment) { 31 | const id = ++uidCounter; 32 | environments.set(id, environment); 33 | 34 | hook.emit('environment', { id, environment }); 35 | 36 | return id; 37 | } 38 | 39 | function sub(event, fn) { 40 | hook.on(event, fn); 41 | return () => hook.off(event, fn); 42 | } 43 | 44 | function on(event, fn) { 45 | if (!listeners[event]) { 46 | listeners[event] = []; 47 | } 48 | listeners[event].push(fn); 49 | } 50 | 51 | function off(event, fn) { 52 | if (!listeners[event]) { 53 | return; 54 | } 55 | const index = listeners[event].indexOf(fn); 56 | if (index !== -1) { 57 | listeners[event].splice(index, 1); 58 | } 59 | if (!listeners[event].length) { 60 | delete listeners[event]; 61 | } 62 | } 63 | 64 | function emit(event, data) { 65 | if (listeners[event]) { 66 | listeners[event].map(fn => fn(data)); 67 | } 68 | } 69 | 70 | const environmentWrappers = new Map(); 71 | 72 | const hook: DevToolsHook = { 73 | registerEnvironment, 74 | environmentWrappers, 75 | // listeners, 76 | environments, 77 | 78 | emit, 79 | // inject, 80 | on, 81 | off, 82 | sub 83 | }; 84 | 85 | Object.defineProperty( 86 | target, 87 | '__RELAY_DEVTOOLS_HOOK__', 88 | ({ 89 | // This property needs to be configurable for the test environment, 90 | // else we won't be able to delete and recreate it beween tests. 91 | configurable: __DEV__, 92 | enumerable: false, 93 | get() { 94 | return hook; 95 | } 96 | }: Object) 97 | ); 98 | 99 | return hook; 100 | } 101 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow 8 | */ 9 | 10 | export type Wall = {| 11 | // `listen` returns the "unlisten" function. 12 | listen: (fn: Function) => Function, 13 | send: (event: string, payload: any, transferable?: Array) => void 14 | |}; 15 | 16 | export type Record = { [key: string]: mixed, ... }; 17 | export type DataID = string; 18 | export type UpdatedRecords = { [dataID: DataID]: boolean, ... }; 19 | 20 | export type StoreRecords = { [DataID]: ?Record, ... }; 21 | 22 | // Copied from relay 23 | export type LogEvent = 24 | | {| 25 | +name: 'queryresource.fetch', 26 | +operation: $FlowFixMe, 27 | // FetchPolicy from relay-experimental 28 | +fetchPolicy: string, 29 | // RenderPolicy from relay-experimental 30 | +renderPolicy: string, 31 | +hasFullQuery: boolean, 32 | +shouldFetch: boolean 33 | |} 34 | | {| 35 | +name: 'store.publish', 36 | +source: any, 37 | +optimistic: boolean 38 | |} 39 | | {| 40 | +name: 'store.gc', 41 | references: Array, 42 | gcRecords: StoreRecords 43 | |} 44 | | {| 45 | +name: 'store.restore' 46 | |} 47 | | {| 48 | +name: 'store.snapshot' 49 | |} 50 | | {| 51 | +name: 'store.notify.start' 52 | |} 53 | | {| 54 | +name: 'store.notify.complete', 55 | +updatedRecordIDs: UpdatedRecords, 56 | +invalidatedRecordIDs: Array, 57 | updatedRecords: StoreRecords, 58 | invalidatedRecords: StoreRecords 59 | |} 60 | | {| 61 | +name: 'execute.info', 62 | +transactionID: number, 63 | +info: mixed, 64 | params: $FlowFixMe, 65 | variables: $FlowFixMe 66 | |} 67 | | {| 68 | +name: 'execute.start', 69 | +transactionID: number, 70 | +params: $FlowFixMe, 71 | +variables: $FlowFixMe 72 | |} 73 | | {| 74 | +name: 'execute.next', 75 | +transactionID: number, 76 | +response: $FlowFixMe, 77 | params: $FlowFixMe, 78 | variables: $FlowFixMe 79 | |} 80 | | {| 81 | +name: 'execute.error', 82 | +transactionID: number, 83 | +error: Error, 84 | params: $FlowFixMe, 85 | variables: $FlowFixMe 86 | |} 87 | | {| 88 | +name: 'execute.complete', 89 | +transactionID: number, 90 | params: $FlowFixMe, 91 | variables: $FlowFixMe 92 | |} 93 | | {| 94 | +name: 'execute.unsubscribe', 95 | +transactionID: number, 96 | params: $FlowFixMe, 97 | variables: $FlowFixMe 98 | |}; 99 | 100 | export type EventData = {| 101 | +id: number, 102 | +data: LogEvent, 103 | +source: StoreRecords, 104 | +eventType: string 105 | |}; 106 | 107 | export type StoreData = {| 108 | +name: string, 109 | +id: number, 110 | +records: StoreRecords 111 | |}; 112 | 113 | export type EnvironmentInfo = {| 114 | +id: number, 115 | +environmentName: string 116 | |}; 117 | --------------------------------------------------------------------------------