ConsoleOutput.js (13616B)
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 Component, 8 createElement, 9 createRef, 10 } = require("resource://devtools/client/shared/vendor/react.mjs"); 11 const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); 12 const { 13 connect, 14 } = require("resource://devtools/client/shared/vendor/react-redux.js"); 15 const { 16 initialize, 17 } = require("resource://devtools/client/webconsole/actions/ui.js"); 18 const LazyMessageList = require("resource://devtools/client/webconsole/components/Output/LazyMessageList.js"); 19 20 const { 21 getMutableMessagesById, 22 getAllMessagesUiById, 23 getAllDisabledMessagesById, 24 getAllCssMessagesMatchingElements, 25 getAllNetworkMessagesUpdateById, 26 getLastMessageId, 27 getVisibleMessages, 28 getAllRepeatById, 29 getAllWarningGroupsById, 30 isMessageInWarningGroup, 31 } = require("resource://devtools/client/webconsole/selectors/messages.js"); 32 33 loader.lazyRequireGetter( 34 this, 35 "PropTypes", 36 "resource://devtools/client/shared/vendor/react-prop-types.js" 37 ); 38 loader.lazyRequireGetter( 39 this, 40 "MessageContainer", 41 "resource://devtools/client/webconsole/components/Output/MessageContainer.js", 42 true 43 ); 44 loader.lazyRequireGetter(this, "flags", "resource://devtools/shared/flags.js"); 45 46 const { 47 MESSAGE_TYPE, 48 } = require("resource://devtools/client/webconsole/constants.js"); 49 50 class ConsoleOutput extends Component { 51 static get propTypes() { 52 return { 53 initialized: PropTypes.bool.isRequired, 54 mutableMessages: PropTypes.object.isRequired, 55 messageCount: PropTypes.number.isRequired, 56 messagesUi: PropTypes.array.isRequired, 57 disabledMessages: PropTypes.array.isRequired, 58 serviceContainer: PropTypes.shape({ 59 attachRefToWebConsoleUI: PropTypes.func.isRequired, 60 openContextMenu: PropTypes.func.isRequired, 61 sourceMapURLService: PropTypes.object, 62 }), 63 dispatch: PropTypes.func.isRequired, 64 timestampsVisible: PropTypes.bool, 65 cssMatchingElements: PropTypes.object.isRequired, 66 messagesRepeat: PropTypes.object.isRequired, 67 warningGroups: PropTypes.object.isRequired, 68 networkMessagesUpdate: PropTypes.object.isRequired, 69 visibleMessages: PropTypes.array.isRequired, 70 networkMessageActiveTabId: PropTypes.string.isRequired, 71 onFirstMeaningfulPaint: PropTypes.func.isRequired, 72 editorMode: PropTypes.bool.isRequired, 73 cacheGeneration: PropTypes.number.isRequired, 74 disableVirtualization: PropTypes.bool, 75 lastMessageId: PropTypes.string, 76 }; 77 } 78 79 constructor(props) { 80 super(props); 81 this.onContextMenu = this.onContextMenu.bind(this); 82 this.maybeScrollToBottom = this.maybeScrollToBottom.bind(this); 83 this.messageIdsToKeepAlive = new Set(); 84 this.ref = createRef(); 85 this.lazyMessageListRef = createRef(); 86 87 this.resizeObserver = new ResizeObserver(() => { 88 // If we don't have the outputNode reference, or if the outputNode isn't connected 89 // anymore, we disconnect the resize observer (componentWillUnmount is never called 90 // on this component, so we have to do it here). 91 if (!this.outputNode || !this.outputNode.isConnected) { 92 this.resizeObserver.disconnect(); 93 return; 94 } 95 96 if (this.scrolledToBottom) { 97 this.scrollToBottom(); 98 } 99 }); 100 } 101 102 componentDidMount() { 103 if (this.props.disableVirtualization) { 104 return; 105 } 106 107 if (this.props.visibleMessages.length) { 108 this.scrollToBottom(); 109 } 110 111 this.scrollDetectionIntersectionObserver = new IntersectionObserver( 112 entries => { 113 for (const entry of entries) { 114 // Consider that we're not pinned to the bottom anymore if the bottom of the 115 // scrollable area is within 10px of visible (half the typical element height.) 116 this.scrolledToBottom = entry.intersectionRatio > 0; 117 } 118 }, 119 { root: this.outputNode, rootMargin: "10px" } 120 ); 121 122 this.resizeObserver.observe(this.getElementToObserve()); 123 124 const { serviceContainer, onFirstMeaningfulPaint, dispatch } = this.props; 125 serviceContainer.attachRefToWebConsoleUI( 126 "outputScroller", 127 this.ref.current 128 ); 129 130 // Waiting for the next paint. 131 new Promise(res => requestAnimationFrame(res)).then(() => { 132 if (onFirstMeaningfulPaint) { 133 onFirstMeaningfulPaint(); 134 } 135 136 // Dispatching on next tick so we don't block on action execution. 137 setTimeout(() => { 138 dispatch(initialize()); 139 }, 0); 140 }); 141 } 142 143 // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 144 UNSAFE_componentWillUpdate(nextProps) { 145 this.isUpdating = true; 146 if (nextProps.cacheGeneration !== this.props.cacheGeneration) { 147 this.messageIdsToKeepAlive = new Set(); 148 } 149 150 if (nextProps.editorMode !== this.props.editorMode) { 151 this.resizeObserver.disconnect(); 152 } 153 154 const { outputNode } = this; 155 if (!outputNode?.lastChild) { 156 // Force a scroll to bottom when messages are added to an empty console. 157 // This makes the console stay pinned to the bottom if a batch of messages 158 // are added after a page refresh (Bug 1402237). 159 this.shouldScrollBottom = true; 160 this.scrolledToBottom = true; 161 return; 162 } 163 164 const bottomBuffer = this.lazyMessageListRef.current.bottomBuffer; 165 this.scrollDetectionIntersectionObserver.unobserve(bottomBuffer); 166 167 const visibleMessagesDelta = 168 nextProps.visibleMessages.length - this.props.visibleMessages.length; 169 const messagesDelta = nextProps.messageCount - this.props.messageCount; 170 // Evaluation results are never filtered out, so if it's in the store, it will be 171 // visible in the output. 172 const isNewMessageEvaluationResult = 173 messagesDelta > 0 && 174 nextProps.lastMessageId && 175 nextProps.mutableMessages.get(nextProps.lastMessageId)?.type === 176 MESSAGE_TYPE.RESULT; 177 178 // Use an inline function in order to avoid executing the expensive Array.some() 179 // unless condition are meant to do this additional check. 180 const isOpeningGroup = () => { 181 const messagesUiDelta = 182 nextProps.messagesUi.length - this.props.messagesUi.length; 183 return ( 184 messagesUiDelta > 0 && 185 nextProps.messagesUi.some( 186 id => 187 !this.props.messagesUi.includes(id) && 188 this.props.visibleMessages.includes(id) && 189 nextProps.visibleMessages.includes(id) 190 ) 191 ); 192 }; 193 194 // We need to scroll to the bottom if: 195 this.shouldScrollBottom = 196 // 1) This is reacting to "initialize" action 197 // 2) And it has scrolled to the bottom 198 (!this.props.initialized && 199 nextProps.initialized && 200 this.scrolledToBottom) || 201 // 1) The number of messages in the store has changed 202 // 2) And the new message is an evaluation result. 203 isNewMessageEvaluationResult || 204 // 1) It is scrolled to the bottom 205 // 2) And the number of messages displayed changed or it is reacting to a network update but there's no new messages being displayed 206 // 3) And if it is not reacting to a group opening. 207 (this.scrolledToBottom && 208 (visibleMessagesDelta > 0 || 209 (visibleMessagesDelta === 0 && 210 // Note: The network updates are throttled and therefore might come in later 211 // so make sure a scroll to bottom is trggered. 212 this.props.networkMessagesUpdate !== 213 nextProps.networkMessagesUpdate)) && 214 !isOpeningGroup()); 215 } 216 217 componentDidUpdate(prevProps) { 218 this.isUpdating = false; 219 this.maybeScrollToBottom(); 220 if (this?.outputNode?.lastChild) { 221 const bottomBuffer = this.lazyMessageListRef.current.bottomBuffer; 222 this.scrollDetectionIntersectionObserver.observe(bottomBuffer); 223 } 224 225 if (prevProps.editorMode !== this.props.editorMode) { 226 this.resizeObserver.observe(this.getElementToObserve()); 227 } 228 } 229 230 get outputNode() { 231 return this.ref.current; 232 } 233 234 maybeScrollToBottom() { 235 if (this.outputNode && this.shouldScrollBottom) { 236 this.scrollToBottom(); 237 } 238 } 239 240 // The maybeScrollToBottom callback we provide to messages needs to be a little bit more 241 // strict than the one we normally use, because they can potentially interrupt a user 242 // scroll (between when the intersection observer registers the scroll break and when 243 // a componentDidUpdate comes through to reconcile it.) 244 maybeScrollToBottomMessageCallback(index) { 245 if ( 246 this.outputNode && 247 this.shouldScrollBottom && 248 this.scrolledToBottom && 249 this.lazyMessageListRef.current?.isItemNearBottom(index) 250 ) { 251 this.scrollToBottom(); 252 } 253 } 254 255 scrollToBottom() { 256 if (flags.testing && this.outputNode.hasAttribute("disable-autoscroll")) { 257 return; 258 } 259 if (this.outputNode.scrollHeight > this.outputNode.clientHeight) { 260 this.outputNode.scrollTop = this.outputNode.scrollHeight; 261 } 262 263 this.scrolledToBottom = true; 264 } 265 266 getElementToObserve() { 267 // In inline mode, we need to observe the output node parent, which contains both the 268 // output and the input, so we don't trigger the resizeObserver callback when only the 269 // output size changes (e.g. when a network request is expanded). 270 return this.props.editorMode 271 ? this.outputNode 272 : this.outputNode?.parentNode; 273 } 274 275 onContextMenu(e) { 276 this.props.serviceContainer.openContextMenu(e); 277 e.stopPropagation(); 278 e.preventDefault(); 279 } 280 281 render() { 282 const { 283 cacheGeneration, 284 dispatch, 285 visibleMessages, 286 disabledMessages, 287 mutableMessages, 288 messagesUi, 289 cssMatchingElements, 290 messagesRepeat, 291 warningGroups, 292 networkMessagesUpdate, 293 networkMessageActiveTabId, 294 serviceContainer, 295 timestampsVisible, 296 } = this.props; 297 298 const renderMessage = (messageId, index) => { 299 return createElement(MessageContainer, { 300 dispatch, 301 key: messageId, 302 messageId, 303 serviceContainer, 304 open: messagesUi.includes(messageId), 305 cssMatchingElements: cssMatchingElements.get(messageId), 306 timestampsVisible, 307 disabled: disabledMessages.includes(messageId), 308 repeat: messagesRepeat[messageId], 309 badge: warningGroups.has(messageId) 310 ? warningGroups.get(messageId).length 311 : null, 312 inWarningGroup: 313 warningGroups && warningGroups.size > 0 314 ? isMessageInWarningGroup( 315 mutableMessages.get(messageId), 316 visibleMessages 317 ) 318 : false, 319 networkMessageUpdate: networkMessagesUpdate[messageId], 320 networkMessageActiveTabId, 321 getMessage: () => mutableMessages.get(messageId), 322 maybeScrollToBottom: () => 323 this.maybeScrollToBottomMessageCallback(index), 324 // Whenever a node is expanded, we want to make sure we keep the 325 // message node alive so as to not lose the expanded state. 326 setExpanded: () => this.messageIdsToKeepAlive.add(messageId), 327 }); 328 }; 329 330 // scrollOverdrawCount tells the list to draw extra elements above and 331 // below the scrollport so that we can avoid flashes of blank space 332 // when scrolling. When `disableVirtualization` is passed we make it as large as the 333 // number of messages to render them all and effectively disabling virtualization (this 334 // should only be used for some actions that requires all the messages to be rendered 335 // in the DOM, like "Copy All Messages"). 336 const scrollOverdrawCount = this.props.disableVirtualization 337 ? visibleMessages.length 338 : 20; 339 340 const attrs = { 341 className: "webconsole-output", 342 role: "main", 343 onContextMenu: this.onContextMenu, 344 ref: this.ref, 345 }; 346 if (flags.testing) { 347 attrs["data-visible-messages"] = JSON.stringify(visibleMessages); 348 } 349 return dom.div( 350 attrs, 351 createElement(LazyMessageList, { 352 viewportRef: this.ref, 353 items: visibleMessages, 354 itemDefaultHeight: 21, 355 editorMode: this.props.editorMode, 356 scrollOverdrawCount, 357 ref: this.lazyMessageListRef, 358 renderItem: renderMessage, 359 itemsToKeepAlive: this.messageIdsToKeepAlive, 360 serviceContainer, 361 cacheGeneration, 362 shouldScrollBottom: () => this.shouldScrollBottom && this.isUpdating, 363 }) 364 ); 365 } 366 } 367 368 function mapStateToProps(state) { 369 const mutableMessages = getMutableMessagesById(state); 370 return { 371 initialized: state.ui.initialized, 372 cacheGeneration: state.ui.cacheGeneration, 373 // We need to compute this so lifecycle methods can compare the global message count 374 // on state change (since we can't do it with mutableMessagesById). 375 messageCount: mutableMessages.size, 376 mutableMessages, 377 lastMessageId: getLastMessageId(state), 378 visibleMessages: getVisibleMessages(state), 379 disabledMessages: getAllDisabledMessagesById(state), 380 messagesUi: getAllMessagesUiById(state), 381 cssMatchingElements: getAllCssMessagesMatchingElements(state), 382 messagesRepeat: getAllRepeatById(state), 383 warningGroups: getAllWarningGroupsById(state), 384 networkMessagesUpdate: getAllNetworkMessagesUpdateById(state), 385 timestampsVisible: state.ui.timestampsVisible, 386 networkMessageActiveTabId: state.ui.networkMessageActiveTabId, 387 }; 388 } 389 390 module.exports = connect(mapStateToProps)(ConsoleOutput);