Re-added "Settings" panel to browser extension and (hopefully) fixed a lot of sources of error

This commit is contained in:
Brian Vaughn 2019-02-14 11:45:11 -08:00
parent 5a301cd26e
commit 15c5396169
17 changed files with 180 additions and 142 deletions

View File

@ -28,8 +28,8 @@
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
"web_accessible_resources": [
"elements.html",
"main.html",
"panel.html",
"settings.html",
"build/backend.js"
],

View File

@ -34,8 +34,8 @@
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
"web_accessible_resources": [
"elements.html",
"main.html",
"panel.html",
"settings.html",
"build/backend.js"
],

View File

@ -10,8 +10,8 @@ const { join } = require('path');
const STATIC_FILES = [
'icons',
'popups',
'elements.html',
'main.html',
'panel.html',
'settings.html',
];

View File

@ -27,6 +27,6 @@
<body>
<!-- main react mount point -->
<div id="container">Unable to find React on the page.</div>
<script src="./build/panel.js"></script>
<script src="./build/elements.js"></script>
</body>
</html>

View File

@ -1,5 +1,8 @@
/* global chrome */
import Bridge from 'src/bridge';
import Store from 'src/devtools/Store';
let panelCreated = false;
function createPanelIfReactLoaded() {
@ -9,37 +12,105 @@ function createPanelIfReactLoaded() {
chrome.devtools.inspectedWindow.eval(
'window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.renderers.size > 0',
function(pageHasReact, err) {
function(pageHasReact, error) {
if (!pageHasReact || panelCreated) {
return;
}
clearInterval(loadCheckInterval);
panelCreated = true;
chrome.devtools.panels.create('⚛ Elements', '', 'panel.html', panel => {
panel.onShown.addListener(function(window) {
// TODO: When the user switches to the panel, check for an Elements tab selection.
});
panel.onHidden.addListener(function() {
// TODO: Stop highlighting and stuff.
});
});
/* TODO Revisit this architecture; currently it causes duplicate agent/bridge traffic.
clearInterval(loadCheckInterval);
let bridge = null;
let store = null;
let elementsPanel = null;
let settingsPanel = null;
function initBridgeAndStore() {
let hasPortBeenDisconnected = false;
const port = chrome.runtime.connect({
name: '' + chrome.devtools.inspectedWindow.tabId,
});
port.onDisconnect.addListener(() => {
hasPortBeenDisconnected = true;
});
bridge = new Bridge({
listen(fn) {
port.onMessage.addListener(message => fn(message));
},
send(event: string, payload: any, transferable?: Array<any>) {
if (!hasPortBeenDisconnected) {
port.postMessage({ event, payload }, transferable);
}
},
});
store = new Store(bridge);
if (elementsPanel !== null) {
elementsPanel.injectBridgeAndStore(bridge, store);
}
if (settingsPanel !== null) {
settingsPanel.injectBridgeAndStore(bridge, store);
}
}
initBridgeAndStore();
chrome.devtools.panels.create(
'⚛ Elements',
'',
'elements.html',
panel => {
panel.onShown.addListener(panel => {
if (elementsPanel === null) {
panel.injectBridgeAndStore(bridge, store);
}
elementsPanel = panel;
// TODO: When the user switches to the panel, check for an Elements tab selection.
});
panel.onHidden.addListener(() => {
// TODO: Stop highlighting and stuff.
});
}
);
chrome.devtools.panels.create(
'⚛ Settings',
'',
'settings.html',
panel => {}
panel => {
panel.onShown.addListener(panel => {
if (settingsPanel === null) {
panel.injectBridgeAndStore(bridge, store);
}
settingsPanel = panel;
});
}
);
*/
chrome.devtools.network.onNavigated.removeListener(checkPageForReact);
// Shutdown bridge and re-initialize DevTools panel when a new page is loaded.
chrome.devtools.network.onNavigated.addListener(function onNavigated() {
bridge.send('shutdown');
initBridgeAndStore();
});
}
);
}
chrome.devtools.network.onNavigated.addListener(function() {
// Load (or reload) the DevTools extension when the user navigates to a new page.
function checkPageForReact() {
createPanelIfReactLoaded();
});
}
chrome.devtools.network.onNavigated.addListener(checkPageForReact);
// Check to see if React has loaded once per second in case React is added
// after page load

View File

@ -1,63 +0,0 @@
/* global chrome */
import { createElement } from 'react';
import { createRoot, flushSync } from 'react-dom';
import Bridge from 'src/bridge';
import DevTools from 'src/devtools/views/DevTools';
import inject from './inject';
import { getBrowserName, getBrowserTheme } from './utils';
const container = ((document.getElementById('container'): any): HTMLElement);
function injectAndInit() {
let disconnected = false;
const port = chrome.runtime.connect({
name: '' + chrome.devtools.inspectedWindow.tabId,
});
port.onDisconnect.addListener(() => {
disconnected = true;
});
const bridge = new Bridge({
listen(fn) {
port.onMessage.addListener(message => fn(message));
},
send(event: string, payload: any, transferable?: Array<any>) {
if (disconnected) {
return;
}
port.postMessage({ event, payload }, transferable);
},
});
// Clear the "React not found" initial message before rendering.
container.innerHTML = '';
const root = createRoot(container);
root.render(
createElement(DevTools, {
bridge,
browserName: getBrowserName(),
browserTheme: getBrowserTheme(),
defaultTab: 'elements',
showTabBar: false,
})
);
// Initialize the backend only once the DevTools frontend Store has been initialized.
// Otherwise the Store may miss important initial tree op codes.
inject(chrome.runtime.getURL('build/backend.js'));
// Reload the DevTools extension when the user navigates to a new page.
function onNavigated() {
chrome.devtools.network.onNavigated.removeListener(onNavigated);
bridge.send('shutdown');
flushSync(() => root.unmount(injectAndInit));
}
chrome.devtools.network.onNavigated.addListener(onNavigated);
}
injectAndInit();

View File

@ -0,0 +1,3 @@
import { createPanel } from './utils';
createPanel('elements');

View File

@ -0,0 +1,3 @@
import { createPanel } from './utils';
createPanel('settings');

View File

@ -0,0 +1,53 @@
/* global chrome */
import { createElement } from 'react';
import { createRoot, flushSync } from 'react-dom';
import DevTools from 'src/devtools/views/DevTools';
import inject from '../inject';
import { getBrowserName, getBrowserTheme } from '../utils';
export function createPanel(defaultTab) {
let injectedBridge = null;
let injectedStore = null;
let root = null;
// All DevTools panel share a single Bridge and Store instance.
// The main script will inject those shared instances using this method.
window.injectBridgeAndStore = (bridge, store) => {
injectedBridge = bridge;
injectedStore = store;
if (root === null) {
injectAndInit();
} else {
// It's easiest to recreate the DevTools panel (to clean up potential stale state).
// We can revisit this in the future as a small optimization.
flushSync(() => root.unmount(injectAndInit));
}
};
function injectAndInit() {
const container = ((document.getElementById(
'container'
): any): HTMLElement);
// Clear the "React not found" initial message before rendering.
container.innerHTML = '';
root = createRoot(container);
root.render(
createElement(DevTools, {
bridge: injectedBridge,
browserName: getBrowserName(),
browserTheme: getBrowserTheme(),
defaultTab,
showTabBar: false,
store: injectedStore,
})
);
// Initialize the backend only once the DevTools frontend Store has been initialized.
// Otherwise the Store may miss important initial tree op codes.
inject(chrome.runtime.getURL('build/backend.js'));
}
}

View File

@ -1,48 +0,0 @@
/* global chrome */
import { createElement } from 'react';
import { createRoot, flushSync } from 'react-dom';
import Bridge from 'src/bridge';
import DevTools from 'src/devtools/views/DevTools';
import inject from './inject';
import { getBrowserName, getBrowserTheme } from './utils';
const container = ((document.getElementById('container'): any): HTMLElement);
function injectAndInit() {
// Noop Bridge for the Settings panel
const bridge = new Bridge({
listen(fn) {},
send(event: string, payload: any, transferable?: Array<any>) {},
});
// Clear the "React not found" initial message before rendering.
container.innerHTML = '';
const root = createRoot(container);
root.render(
createElement(DevTools, {
bridge,
browserName: getBrowserName(),
browserTheme: getBrowserTheme(),
defaultTab: 'settings',
showTabBar: false,
})
);
// Initialize the backend only once the DevTools frontend Store has been initialized.
// Otherwise the Store may miss important initial tree op codes.
inject(chrome.runtime.getURL('build/backend.js'));
// Reload the DevTools extension when the user navigates to a new page.
function onNavigated() {
chrome.devtools.network.onNavigated.removeListener(onNavigated);
bridge.send('shutdown');
flushSync(() => root.unmount(injectAndInit));
}
chrome.devtools.network.onNavigated.addListener(onNavigated);
}
injectAndInit();

View File

@ -16,8 +16,8 @@ module.exports = {
contentScript: './src/contentScript.js',
inject: './src/GlobalHook.js',
main: './src/main.js',
panel: './src/panel.js',
settings: './src/settings.js',
elements: './src/panels/elements.js',
settings: './src/panels/settings.js',
},
output: {
path: __dirname + '/build',

View File

@ -6,6 +6,7 @@ import { createRoot } from 'react-dom';
import Bridge from 'src/bridge';
import { installHook } from 'src/hook';
import { initDevTools } from 'src/devtools';
import Store from 'src/devtools/Store';
import DevTools from 'src/devtools/views/DevTools';
const iframe = ((document.getElementById('target'): any): HTMLIFrameElement);
@ -53,6 +54,8 @@ inject('./build/app.js', () => {
cb(bridge);
const store = new Store(bridge);
const root = createRoot(container);
const batch = root.createBatch();
batch.render(
@ -61,6 +64,7 @@ inject('./build/app.js', () => {
browserName: 'Chrome',
browserTheme: 'light',
showTabBar: true,
store,
})
);
batch.then(() => {

View File

@ -25,11 +25,11 @@ export default class Agent extends EventEmitter {
addBridge(bridge: Bridge) {
this._bridge = bridge;
bridge.addListener('shutdown', () => this.emit('shutdown'));
bridge.addListener('highlightElementInDOM', this.highlightElementInDOM);
bridge.addListener('inspectElement', this.inspectElement);
bridge.addListener('selectElement', this.selectElement);
bridge.addListener('shutdown', this.shutdown);
// TODO Listen to bridge for things like selection.
// bridge.on('...'), this...);
}
@ -94,8 +94,17 @@ export default class Agent extends EventEmitter {
this._rendererInterfaces[rendererID] = rendererInterface;
}
shutdown = () => {
this.emit('shutdown');
};
onHookOperations = (operations: Uint32Array) => {
debug('onHookOperations', operations);
this._bridge.send('operations', operations, [operations.buffer]);
// TODO The chrome.runtime does not currently support transferables; it forces JSON serialization.
// The Store has a fallback in place that parses the message as JSON if the type isn't an array.
// Sometimes using transferrables also cause Chrome or Firefox to throw "ArrayBuffer at index 0 is already neutered".
// this._bridge.send('operations', operations, [operations.buffer]);
this._bridge.send('operations', operations);
};
}

View File

@ -28,6 +28,8 @@ const debug = (methodName, ...args) => {
* ContextProviders can subscribe to the Store for specific things they want to provide.
*/
export default class Store extends EventEmitter {
_bridge: Bridge;
// Map of ID to Element.
// Elements are mutable (for now) to avoid excessive cloning during tree updates.
_idToElement: Map<number, Element> = new Map();
@ -52,8 +54,9 @@ export default class Store extends EventEmitter {
debug('constructor', 'subscribing to Bridge');
bridge.on('operations', this.onBridgeOperations);
bridge.on('shutdown', this.onBridgeShutdown);
this._bridge = bridge;
this._bridge.on('operations', this.onBridgeOperations);
this._bridge.on('shutdown', this.onBridgeShutdown);
}
get numElements(): number {
@ -402,8 +405,8 @@ export default class Store extends EventEmitter {
onBridgeShutdown = () => {
debug('onBridgeShutdown', 'unsubscribing from Bridge');
bridge.off('operations', this.onBridgeOperations);
bridge.off('shutdown', this.onBridgeShutdown);
this._bridge.off('operations', this.onBridgeOperations);
this._bridge.off('shutdown', this.onBridgeShutdown);
};
// DEBUG

View File

@ -1,6 +1,6 @@
// @flow
import React, { useMemo, useState } from 'react';
import React, { useState } from 'react';
import Store from '../store';
import { BridgeContext, StoreContext } from './context';
import Elements from './Elements';
@ -26,6 +26,7 @@ export type Props = {|
defaultTab?: TabID,
browserTheme: BrowserTheme,
showTabBar?: boolean,
store: Store,
|};
export default function DevTools({
@ -34,8 +35,8 @@ export default function DevTools({
defaultTab = 'elements',
browserTheme = 'light',
showTabBar = false,
store,
}: Props) {
const store = useMemo<Store>(() => new Store(bridge), []);
const [tab, setTab] = useState(defaultTab);
let tabElement;

View File

@ -44,6 +44,8 @@ export default function ElementView({ index, style }: Props) {
const isSelected = selectedElementID === id;
const showDollarR = isSelected && type === ElementTypeClassOrFunction;
// TODO styles.SelectedElement is 100% width but it doesn't take horizontal overflow into account.
return (
<div
className={isSelected ? styles.SelectedElement : styles.Element}

View File

@ -524,7 +524,7 @@ function TreeContextController({ children }: {| children: React$Node |}) {
? store.getElementAtIndex(index)
: store.getElementByID(state._ownerFlatTree[index]);
},
[state]
[state, store]
);
const selectElementAtIndex = useCallback(
(index: number) =>