diff options
Diffstat (limited to '')
-rw-r--r-- | devtools/client/webconsole/webconsole-wrapper.js | 475 |
1 files changed, 475 insertions, 0 deletions
diff --git a/devtools/client/webconsole/webconsole-wrapper.js b/devtools/client/webconsole/webconsole-wrapper.js new file mode 100644 index 0000000000..f1768f8dd3 --- /dev/null +++ b/devtools/client/webconsole/webconsole-wrapper.js @@ -0,0 +1,475 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + createElement, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom.js"); +const { + Provider, + createProvider, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +const actions = require("resource://devtools/client/webconsole/actions/index.js"); +const { + configureStore, +} = require("resource://devtools/client/webconsole/store.js"); + +const { + isPacketPrivate, +} = require("resource://devtools/client/webconsole/utils/messages.js"); +const { + getMutableMessagesById, + getMessage, + getAllNetworkMessagesUpdateById, +} = require("resource://devtools/client/webconsole/selectors/messages.js"); + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const App = createFactory( + require("resource://devtools/client/webconsole/components/App.js") +); + +loader.lazyGetter(this, "AppErrorBoundary", () => + createFactory( + require("resource://devtools/client/shared/components/AppErrorBoundary.js") + ) +); + +const { + setupServiceContainer, +} = require("resource://devtools/client/webconsole/service-container.js"); + +loader.lazyRequireGetter( + this, + "Constants", + "resource://devtools/client/webconsole/constants.js" +); + +// Localized strings for (devtools/client/locales/en-US/startup.properties) +loader.lazyGetter(this, "L10N", function () { + const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); + return new LocalizationHelper("devtools/client/locales/startup.properties"); +}); + +// Only Browser Console needs Fluent bundles at the moment +loader.lazyRequireGetter( + this, + "FluentL10n", + "resource://devtools/client/shared/fluent-l10n/fluent-l10n.js", + true +); +loader.lazyRequireGetter( + this, + "LocalizationProvider", + "resource://devtools/client/shared/vendor/fluent-react.js", + true +); + +let store = null; + +class WebConsoleWrapper { + /** + * + * @param {HTMLElement} parentNode + * @param {WebConsoleUI} webConsoleUI + * @param {Toolbox} toolbox + * @param {Document} document + * + */ + constructor(parentNode, webConsoleUI, toolbox, document) { + EventEmitter.decorate(this); + + this.parentNode = parentNode; + this.webConsoleUI = webConsoleUI; + this.toolbox = toolbox; + this.hud = this.webConsoleUI.hud; + this.document = document; + + this.init = this.init.bind(this); + + this.queuedMessageAdds = []; + this.queuedMessageUpdates = []; + this.queuedRequestUpdates = []; + this.throttledDispatchPromise = null; + + this.telemetry = this.hud.telemetry; + } + + #serviceContainer; + + async init() { + const { webConsoleUI } = this; + + let fluentBundles; + if (webConsoleUI.isBrowserConsole) { + const fluentL10n = new FluentL10n(); + await fluentL10n.init(["devtools/client/toolbox.ftl"]); + fluentBundles = fluentL10n.getBundles(); + } + + return new Promise(resolve => { + store = configureStore(this.webConsoleUI, { + // We may not have access to the toolbox (e.g. in the browser console). + telemetry: this.telemetry, + thunkArgs: { + webConsoleUI, + hud: this.hud, + toolbox: this.toolbox, + commands: this.hud.commands, + }, + }); + + const app = AppErrorBoundary( + { + componentName: "Console", + panel: L10N.getStr("ToolboxTabWebconsole.label"), + }, + App({ + serviceContainer: this.getServiceContainer(), + webConsoleUI, + onFirstMeaningfulPaint: resolve, + closeSplitConsole: this.closeSplitConsole.bind(this), + inputEnabled: + !webConsoleUI.isBrowserConsole || + Services.prefs.getBoolPref("devtools.chrome.enabled"), + }) + ); + + // Render the root Application component. + if (this.parentNode) { + const maybeLocalizedElement = fluentBundles + ? createElement(LocalizationProvider, { bundles: fluentBundles }, app) + : app; + + this.body = ReactDOM.render( + createElement( + Provider, + { store }, + createElement( + createProvider(this.hud.commands.targetCommand.storeId), + { store: this.hud.commands.targetCommand.store }, + maybeLocalizedElement + ) + ), + this.parentNode + ); + } else { + // If there's no parentNode, we are in a test. So we can resolve immediately. + resolve(); + } + }); + } + + destroy() { + // This component can be instantiated from jest test, in which case we don't have + // a parentNode reference. + if (this.parentNode) { + ReactDOM.unmountComponentAtNode(this.parentNode); + } + } + + dispatchMessageAdd(packet) { + this.batchedMessagesAdd([packet]); + } + + dispatchMessagesAdd(messages) { + this.batchedMessagesAdd(messages); + } + + dispatchNetworkMessagesDisable() { + const networkMessageIds = Object.keys( + getAllNetworkMessagesUpdateById(store.getState()) + ); + store.dispatch(actions.messagesDisable(networkMessageIds)); + } + + dispatchMessagesClear() { + // We might still have pending message additions and updates when the clear action is + // triggered, so we need to flush them to make sure we don't have unexpected behavior + // in the ConsoleOutput. *But* we want to keep any pending navigation request, + // as we want to keep displaying them even if we received a clear request. + function filter(l) { + return l.filter(update => update.isNavigationRequest); + } + this.queuedMessageAdds = filter(this.queuedMessageAdds); + this.queuedMessageUpdates = filter(this.queuedMessageUpdates); + this.queuedRequestUpdates = this.queuedRequestUpdates.filter( + update => update.data.isNavigationRequest + ); + + store?.dispatch(actions.messagesClear()); + this.webConsoleUI.emitForTests("messages-cleared"); + } + + dispatchPrivateMessagesClear() { + // We might still have pending private message additions when the private messages + // clear action is triggered. We need to remove any private-window-issued packets from + // the queue so they won't appear in the output. + + // For (network) message updates, we need to check both messages queue and the state + // since we can receive updates even if the message isn't rendered yet. + const messages = [...getMutableMessagesById(store.getState()).values()]; + this.queuedMessageUpdates = this.queuedMessageUpdates.filter( + ({ actor }) => { + const queuedNetworkMessage = this.queuedMessageAdds.find( + p => p.actor === actor + ); + if (queuedNetworkMessage && isPacketPrivate(queuedNetworkMessage)) { + return false; + } + + const requestMessage = messages.find( + message => actor === message.actor + ); + if (requestMessage && requestMessage.private === true) { + return false; + } + + return true; + } + ); + + // For (network) requests updates, we can check only the state, since there must be a + // user interaction to get an update (i.e. the network message is displayed and thus + // in the state). + this.queuedRequestUpdates = this.queuedRequestUpdates.filter(({ id }) => { + const requestMessage = getMessage(store.getState(), id); + if (requestMessage && requestMessage.private === true) { + return false; + } + + return true; + }); + + // Finally we clear the messages queue. This needs to be done here since we use it to + // clean the other queues. + this.queuedMessageAdds = this.queuedMessageAdds.filter( + p => !isPacketPrivate(p) + ); + + store.dispatch(actions.privateMessagesClear()); + } + + dispatchTargetMessagesRemove(targetFront) { + // We might still have pending packets in the queues from the target that we need to remove + // to prevent messages appearing in the output. + + for (let i = this.queuedMessageUpdates.length - 1; i >= 0; i--) { + const packet = this.queuedMessageUpdates[i]; + if (packet.targetFront == targetFront) { + this.queuedMessageUpdates.splice(i, 1); + } + } + + for (let i = this.queuedRequestUpdates.length - 1; i >= 0; i--) { + const packet = this.queuedRequestUpdates[i]; + if (packet.data.targetFront == targetFront) { + this.queuedRequestUpdates.splice(i, 1); + } + } + + for (let i = this.queuedMessageAdds.length - 1; i >= 0; i--) { + const packet = this.queuedMessageAdds[i]; + // Keep in sync with the check done in the reducer for the TARGET_MESSAGES_REMOVE action. + if ( + packet.targetFront == targetFront && + packet.type !== Constants.MESSAGE_TYPE.COMMAND && + packet.type !== Constants.MESSAGE_TYPE.RESULT + ) { + this.queuedMessageAdds.splice(i, 1); + } + } + + store.dispatch(actions.targetMessagesRemove(targetFront)); + } + + dispatchMessagesUpdate(messages) { + this.batchedMessagesUpdates(messages); + } + + dispatchSidebarClose() { + store.dispatch(actions.sidebarClose()); + } + + dispatchSplitConsoleCloseButtonToggle() { + store.dispatch( + actions.splitConsoleCloseButtonToggle( + this.toolbox && this.toolbox.currentToolId !== "webconsole" + ) + ); + } + + dispatchTabWillNavigate(packet) { + const { ui } = store.getState(); + + // For the browser console, we receive tab navigation + // when the original top level window we attached to is closed, + // but we don't want to reset console history and just switch to + // the next available window. + if (ui.persistLogs || this.webConsoleUI.isBrowserConsole) { + // Add a type in order for this event packet to be identified by + // utils/messages.js's `transformPacket` + packet.type = "will-navigate"; + this.dispatchMessageAdd(packet); + } else { + this.dispatchMessagesClear(); + store.dispatch({ + type: Constants.WILL_NAVIGATE, + }); + } + } + + batchedMessagesUpdates(messages) { + if (messages.length) { + this.queuedMessageUpdates.push(...messages); + this.setTimeoutIfNeeded(); + } + } + + batchedRequestUpdates(message) { + this.queuedRequestUpdates.push(message); + return this.setTimeoutIfNeeded(); + } + + batchedMessagesAdd(messages) { + if (messages.length) { + this.queuedMessageAdds.push(...messages); + this.setTimeoutIfNeeded(); + } + } + + dispatchClearHistory() { + store.dispatch(actions.clearHistory()); + } + + /** + * + * @param {String} expression: The expression to evaluate + */ + dispatchEvaluateExpression(expression) { + store.dispatch(actions.evaluateExpression(expression)); + } + + dispatchUpdateInstantEvaluationResultForCurrentExpression() { + store.dispatch(actions.updateInstantEvaluationResultForCurrentExpression()); + } + + /** + * Returns a Promise that resolves once any async dispatch is finally dispatched. + */ + waitAsyncDispatches() { + if (!this.throttledDispatchPromise) { + return Promise.resolve(); + } + // When closing the console during initialization, + // setTimeoutIfNeeded may never resolve its promise + // as window.setTimeout will be disabled on document destruction. + const onUnload = new Promise(r => + window.addEventListener("unload", r, { once: true }) + ); + return Promise.race([this.throttledDispatchPromise, onUnload]); + } + + setTimeoutIfNeeded() { + if (this.throttledDispatchPromise) { + return this.throttledDispatchPromise; + } + this.throttledDispatchPromise = new Promise(done => { + setTimeout(async () => { + this.throttledDispatchPromise = null; + + if (!store) { + // The store is not initialized yet, we can call setTimeoutIfNeeded so the + // messages will be handled in the next timeout when the store is ready. + this.setTimeoutIfNeeded(); + done(); + return; + } + + store.dispatch(actions.messagesAdd(this.queuedMessageAdds)); + + const { length } = this.queuedMessageAdds; + + // This telemetry event is only useful when we have a toolbox so only + // send it when we have one. + if (this.toolbox) { + this.telemetry.addEventProperty( + this.toolbox, + "enter", + "webconsole", + null, + "message_count", + length + ); + } + + this.queuedMessageAdds = []; + + if (this.queuedMessageUpdates.length) { + await store.dispatch( + actions.networkMessageUpdates(this.queuedMessageUpdates, null) + ); + this.webConsoleUI.emitForTests("network-messages-updated"); + this.queuedMessageUpdates = []; + } + if (this.queuedRequestUpdates.length) { + await store.dispatch( + actions.networkUpdateRequests(this.queuedRequestUpdates) + ); + const updateCount = this.queuedRequestUpdates.length; + this.queuedRequestUpdates = []; + + // Fire an event indicating that all data fetched from + // the backend has been received. This is based on + // 'FirefoxDataProvider.isQueuePayloadReady', see more + // comments in that method. + // (netmonitor/src/connector/firefox-data-provider). + // This event might be utilized in tests to find the right + // time when to finish. + + this.webConsoleUI.emitForTests( + "network-request-payload-ready", + updateCount + ); + } + done(); + }, 50); + }); + return this.throttledDispatchPromise; + } + + getStore() { + return store; + } + + getServiceContainer() { + if (!this.#serviceContainer) { + this.#serviceContainer = setupServiceContainer({ + webConsoleUI: this.webConsoleUI, + toolbox: this.toolbox, + hud: this.hud, + webConsoleWrapper: this, + }); + } + return this.#serviceContainer; + } + + subscribeToStore(callback) { + store.subscribe(() => callback(store.getState())); + } + + createElement(nodename) { + return this.document.createElement(nodename); + } + + // Called by pushing close button. + closeSplitConsole() { + this.toolbox.closeSplitConsole(); + } +} + +// Exports from this module +module.exports = WebConsoleWrapper; |