webconsole-wrapper.js (15546B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 "use strict"; 5 6 const { 7 createElement, 8 createFactory, 9 } = require("resource://devtools/client/shared/vendor/react.mjs"); 10 const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom.mjs"); 11 const { 12 Provider, 13 createProvider, 14 } = require("resource://devtools/client/shared/vendor/react-redux.js"); 15 16 const actions = require("resource://devtools/client/webconsole/actions/index.js"); 17 const { 18 configureStore, 19 } = require("resource://devtools/client/webconsole/store.js"); 20 21 const { 22 isPacketPrivate, 23 } = require("resource://devtools/client/webconsole/utils/messages.js"); 24 const { 25 getMutableMessagesById, 26 getMessage, 27 getAllNetworkMessagesUpdateById, 28 } = require("resource://devtools/client/webconsole/selectors/messages.js"); 29 30 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 31 const App = createFactory( 32 require("resource://devtools/client/webconsole/components/App.js") 33 ); 34 const { 35 getAllFilters, 36 } = require("resource://devtools/client/webconsole/selectors/filters.js"); 37 38 loader.lazyGetter(this, "AppErrorBoundary", () => 39 createFactory( 40 require("resource://devtools/client/shared/components/AppErrorBoundary.js") 41 ) 42 ); 43 44 const { 45 setupServiceContainer, 46 } = require("resource://devtools/client/webconsole/service-container.js"); 47 48 loader.lazyRequireGetter( 49 this, 50 "Constants", 51 "resource://devtools/client/webconsole/constants.js" 52 ); 53 54 // Localized strings for (devtools/client/locales/en-US/startup.properties) 55 loader.lazyGetter(this, "L10N", function () { 56 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); 57 return new LocalizationHelper("devtools/client/locales/startup.properties"); 58 }); 59 60 // Only Browser Console needs Fluent bundles at the moment 61 loader.lazyRequireGetter( 62 this, 63 "FluentL10n", 64 "resource://devtools/client/shared/fluent-l10n/fluent-l10n.js", 65 true 66 ); 67 loader.lazyRequireGetter( 68 this, 69 "LocalizationProvider", 70 "resource://devtools/client/shared/vendor/fluent-react.js", 71 true 72 ); 73 74 let store = null; 75 76 class WebConsoleWrapper { 77 /** 78 * 79 * @param {HTMLElement} parentNode 80 * @param {WebConsoleUI} webConsoleUI 81 * @param {Toolbox} toolbox 82 * @param {Document} document 83 */ 84 constructor(parentNode, webConsoleUI, toolbox, document) { 85 EventEmitter.decorate(this); 86 87 this.parentNode = parentNode; 88 this.webConsoleUI = webConsoleUI; 89 this.toolbox = toolbox; 90 this.hud = this.webConsoleUI.hud; 91 this.document = document; 92 93 this.init = this.init.bind(this); 94 95 this.queuedMessageAdds = []; 96 this.queuedMessageUpdates = []; 97 this.queuedRequestUpdates = []; 98 this.throttledDispatchPromise = null; 99 100 this.telemetry = this.hud.telemetry; 101 } 102 103 #serviceContainer; 104 105 async init() { 106 const { webConsoleUI } = this; 107 108 let fluentBundles; 109 if (webConsoleUI.isBrowserConsole) { 110 const fluentL10n = new FluentL10n(); 111 await fluentL10n.init(["devtools/client/toolbox.ftl"]); 112 fluentBundles = fluentL10n.getBundles(); 113 } 114 115 return new Promise(resolve => { 116 store = configureStore(this.webConsoleUI, { 117 // We may not have access to the toolbox (e.g. in the browser console). 118 telemetry: this.telemetry, 119 thunkArgs: { 120 webConsoleUI, 121 hud: this.hud, 122 toolbox: this.toolbox, 123 commands: this.hud.commands, 124 }, 125 }); 126 127 const serviceContainer = this.getServiceContainer(); 128 129 const app = AppErrorBoundary( 130 { 131 componentName: "Console", 132 panel: L10N.getStr("ToolboxTabWebconsole.label"), 133 // The AppErrorBoundary renders a link to file a bug, but in the case of the 134 // browser console, we need to have a specific handler to open the link in the 135 // main Firefox window 136 openLink: webConsoleUI.isBrowserConsole 137 ? serviceContainer.openLink 138 : null, 139 }, 140 App({ 141 serviceContainer, 142 webConsoleUI, 143 onFirstMeaningfulPaint: resolve, 144 closeSplitConsole: this.closeSplitConsole.bind(this), 145 inputEnabled: 146 !webConsoleUI.isBrowserConsole || 147 Services.prefs.getBoolPref("devtools.chrome.enabled"), 148 }) 149 ); 150 151 // Render the root Application component. 152 if (this.parentNode) { 153 const maybeLocalizedElement = fluentBundles 154 ? createElement(LocalizationProvider, { bundles: fluentBundles }, app) 155 : app; 156 157 this.body = ReactDOM.render( 158 createElement( 159 Provider, 160 { store }, 161 createElement( 162 createProvider(this.hud.commands.targetCommand.storeId), 163 { store: this.hud.commands.targetCommand.store }, 164 maybeLocalizedElement 165 ) 166 ), 167 this.parentNode 168 ); 169 } else { 170 // If there's no parentNode, we are in a test. So we can resolve immediately. 171 resolve(); 172 } 173 }); 174 } 175 176 destroy() { 177 // This component can be instantiated from jest test, in which case we don't have 178 // a parentNode reference. 179 if (this.parentNode) { 180 ReactDOM.unmountComponentAtNode(this.parentNode); 181 } 182 } 183 184 /** 185 * Query the reducer store for the current state of filtering 186 * a given type of message 187 * 188 * @param {string} filter 189 * Type of message to be filtered. 190 * @return {boolean} 191 * True if this type of message should be displayed. 192 */ 193 getFilterState(filter) { 194 return getAllFilters(this.getStore().getState())[filter]; 195 } 196 197 dispatchMessageAdd(packet) { 198 this.batchedMessagesAdd([packet]); 199 } 200 201 dispatchMessagesAdd(messages) { 202 this.batchedMessagesAdd(messages); 203 } 204 205 dispatchNetworkMessagesDisable() { 206 const networkMessageIds = Object.keys( 207 getAllNetworkMessagesUpdateById(store.getState()) 208 ); 209 store.dispatch(actions.messagesDisable(networkMessageIds)); 210 } 211 212 dispatchMessagesClear() { 213 // We might still have pending message additions and updates when the clear action is 214 // triggered, so we need to flush them to make sure we don't have unexpected behavior 215 // in the ConsoleOutput. *But* we want to keep any pending navigation request, 216 // as we want to keep displaying them even if we received a clear request. 217 function filter(l) { 218 return l.filter(update => update.isNavigationRequest); 219 } 220 this.queuedMessageAdds = filter(this.queuedMessageAdds); 221 this.queuedMessageUpdates = filter(this.queuedMessageUpdates); 222 this.queuedRequestUpdates = this.queuedRequestUpdates.filter( 223 update => update.data.isNavigationRequest 224 ); 225 226 store?.dispatch(actions.messagesClear()); 227 this.webConsoleUI.emitForTests("messages-cleared"); 228 } 229 230 dispatchPrivateMessagesClear() { 231 // We might still have pending private message additions when the private messages 232 // clear action is triggered. We need to remove any private-window-issued packets from 233 // the queue so they won't appear in the output. 234 235 // For (network) message updates, we need to check both messages queue and the state 236 // since we can receive updates even if the message isn't rendered yet. 237 const messages = [...getMutableMessagesById(store.getState()).values()]; 238 this.queuedMessageUpdates = this.queuedMessageUpdates.filter( 239 ({ actor }) => { 240 const queuedNetworkMessage = this.queuedMessageAdds.find( 241 p => p.actor === actor 242 ); 243 if (queuedNetworkMessage && isPacketPrivate(queuedNetworkMessage)) { 244 return false; 245 } 246 247 const requestMessage = messages.find( 248 message => actor === message.actor 249 ); 250 if (requestMessage && requestMessage.private === true) { 251 return false; 252 } 253 254 return true; 255 } 256 ); 257 258 // For (network) requests updates, we can check only the state, since there must be a 259 // user interaction to get an update (i.e. the network message is displayed and thus 260 // in the state). 261 this.queuedRequestUpdates = this.queuedRequestUpdates.filter(({ id }) => { 262 const requestMessage = getMessage(store.getState(), id); 263 if (requestMessage && requestMessage.private === true) { 264 return false; 265 } 266 267 return true; 268 }); 269 270 // Finally we clear the messages queue. This needs to be done here since we use it to 271 // clean the other queues. 272 this.queuedMessageAdds = this.queuedMessageAdds.filter( 273 p => !isPacketPrivate(p) 274 ); 275 276 store.dispatch(actions.privateMessagesClear()); 277 } 278 279 dispatchTargetMessagesRemove(targetFront) { 280 // We might still have pending packets in the queues from the target that we need to remove 281 // to prevent messages appearing in the output. 282 283 for (let i = this.queuedMessageUpdates.length - 1; i >= 0; i--) { 284 const packet = this.queuedMessageUpdates[i]; 285 if (packet.targetFront == targetFront) { 286 this.queuedMessageUpdates.splice(i, 1); 287 } 288 } 289 290 for (let i = this.queuedRequestUpdates.length - 1; i >= 0; i--) { 291 const packet = this.queuedRequestUpdates[i]; 292 if (packet.data.targetFront == targetFront) { 293 this.queuedRequestUpdates.splice(i, 1); 294 } 295 } 296 297 for (let i = this.queuedMessageAdds.length - 1; i >= 0; i--) { 298 const packet = this.queuedMessageAdds[i]; 299 // Keep in sync with the check done in the reducer for the TARGET_MESSAGES_REMOVE action. 300 if ( 301 packet.targetFront == targetFront && 302 packet.type !== Constants.MESSAGE_TYPE.COMMAND && 303 packet.type !== Constants.MESSAGE_TYPE.RESULT 304 ) { 305 this.queuedMessageAdds.splice(i, 1); 306 } 307 } 308 309 store.dispatch(actions.targetMessagesRemove(targetFront)); 310 } 311 312 dispatchMessagesUpdate(messages) { 313 this.batchedMessagesUpdates(messages); 314 } 315 316 dispatchSidebarClose() { 317 store.dispatch(actions.sidebarClose()); 318 } 319 320 dispatchSplitConsoleCloseButtonToggle() { 321 store.dispatch( 322 actions.splitConsoleCloseButtonToggle( 323 this.toolbox && this.toolbox.currentToolId !== "webconsole" 324 ) 325 ); 326 } 327 328 dispatchTabWillNavigate(packet) { 329 const { ui } = store.getState(); 330 331 // For the browser console, we receive tab navigation 332 // when the original top level window we attached to is closed, 333 // but we don't want to reset console history and just switch to 334 // the next available window. 335 if (ui.persistLogs || this.webConsoleUI.isBrowserConsole) { 336 // Add a type in order for this event packet to be identified by 337 // utils/messages.js's `transformPacket` 338 packet.type = "will-navigate"; 339 this.dispatchMessageAdd(packet); 340 } else { 341 this.dispatchMessagesClear(); 342 store.dispatch({ 343 type: Constants.WILL_NAVIGATE, 344 }); 345 } 346 } 347 348 batchedMessagesUpdates(messages) { 349 if (messages.length) { 350 this.queuedMessageUpdates.push(...messages); 351 this.setTimeoutIfNeeded(); 352 } 353 } 354 355 batchedRequestUpdates(message) { 356 this.queuedRequestUpdates.push(message); 357 return this.setTimeoutIfNeeded(); 358 } 359 360 batchedMessagesAdd(messages) { 361 if (messages.length) { 362 this.queuedMessageAdds.push(...messages); 363 this.setTimeoutIfNeeded(); 364 } 365 } 366 367 dispatchClearHistory() { 368 store.dispatch(actions.clearHistory()); 369 } 370 371 /** 372 * 373 * @param {string} expression: The expression to evaluate 374 */ 375 dispatchEvaluateExpression(expression) { 376 store.dispatch(actions.evaluateExpression(expression)); 377 } 378 379 dispatchUpdateInstantEvaluationResultForCurrentExpression() { 380 store.dispatch(actions.updateInstantEvaluationResultForCurrentExpression()); 381 } 382 383 /** 384 * Returns a Promise that resolves once any async dispatch is finally dispatched. 385 */ 386 waitAsyncDispatches() { 387 if (!this.throttledDispatchPromise) { 388 return Promise.resolve(); 389 } 390 // When closing the console during initialization, 391 // setTimeoutIfNeeded may never resolve its promise 392 // as window.setTimeout will be disabled on document destruction. 393 const onUnload = new Promise(r => 394 window.addEventListener("unload", r, { once: true }) 395 ); 396 return Promise.race([this.throttledDispatchPromise, onUnload]); 397 } 398 399 setTimeoutIfNeeded() { 400 if (this.throttledDispatchPromise) { 401 return this.throttledDispatchPromise; 402 } 403 this.throttledDispatchPromise = new Promise(done => { 404 setTimeout(async () => { 405 this.throttledDispatchPromise = null; 406 407 if (!store) { 408 // The store is not initialized yet, we can call setTimeoutIfNeeded so the 409 // messages will be handled in the next timeout when the store is ready. 410 this.setTimeoutIfNeeded(); 411 done(); 412 return; 413 } 414 415 const { ui } = store.getState(); 416 store.dispatch( 417 actions.messagesAdd(this.queuedMessageAdds, null, ui.persistLogs) 418 ); 419 420 const { length } = this.queuedMessageAdds; 421 422 // This telemetry event is only useful when we have a toolbox so only 423 // send it when we have one. 424 if (this.toolbox) { 425 this.telemetry.addEventProperty( 426 this.toolbox, 427 "enter", 428 "webconsole", 429 null, 430 "message_count", 431 length 432 ); 433 } 434 435 this.queuedMessageAdds = []; 436 437 if (this.queuedMessageUpdates.length) { 438 await store.dispatch( 439 actions.networkMessageUpdates(this.queuedMessageUpdates) 440 ); 441 this.webConsoleUI.emitForTests("network-messages-updated"); 442 this.queuedMessageUpdates = []; 443 } 444 if (this.queuedRequestUpdates.length) { 445 await store.dispatch( 446 actions.networkUpdateRequests(this.queuedRequestUpdates) 447 ); 448 const updateCount = this.queuedRequestUpdates.length; 449 this.queuedRequestUpdates = []; 450 451 // Fire an event indicating that all data fetched from 452 // the backend has been received. This is based on 453 // 'FirefoxDataProvider.isQueuePayloadReady', see more 454 // comments in that method. 455 // (netmonitor/src/connector/firefox-data-provider). 456 // This event might be utilized in tests to find the right 457 // time when to finish. 458 459 this.webConsoleUI.emitForTests( 460 "network-request-payload-ready", 461 updateCount 462 ); 463 } 464 done(); 465 }, 50); 466 }); 467 return this.throttledDispatchPromise; 468 } 469 470 getStore() { 471 return store; 472 } 473 474 getServiceContainer() { 475 if (!this.#serviceContainer) { 476 this.#serviceContainer = setupServiceContainer({ 477 webConsoleUI: this.webConsoleUI, 478 toolbox: this.toolbox, 479 hud: this.hud, 480 webConsoleWrapper: this, 481 }); 482 } 483 return this.#serviceContainer; 484 } 485 486 subscribeToStore(callback) { 487 store.subscribe(() => callback(store.getState())); 488 } 489 490 createElement(nodename) { 491 return this.document.createElement(nodename); 492 } 493 494 // Called by pushing close button. 495 closeSplitConsole() { 496 this.toolbox.closeSplitConsole(); 497 } 498 499 toggleOriginalVariableMappingEvaluationNotification(show) { 500 store.dispatch( 501 actions.showEvaluationNotification( 502 show ? Constants.ORIGINAL_VARIABLE_MAPPING : "" 503 ) 504 ); 505 } 506 } 507 508 // Exports from this module 509 module.exports = WebConsoleWrapper;