/* 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 { Component, createElement, createRef, } = require("resource://devtools/client/shared/vendor/react.js"); const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); const { connect, } = require("resource://devtools/client/shared/redux/visibility-handler-connect.js"); const { initialize, } = require("resource://devtools/client/webconsole/actions/ui.js"); const LazyMessageList = require("resource://devtools/client/webconsole/components/Output/LazyMessageList.js"); const { getMutableMessagesById, getAllMessagesUiById, getAllDisabledMessagesById, getAllCssMessagesMatchingElements, getAllNetworkMessagesUpdateById, getLastMessageId, getVisibleMessages, getAllRepeatById, getAllWarningGroupsById, isMessageInWarningGroup, } = require("resource://devtools/client/webconsole/selectors/messages.js"); loader.lazyRequireGetter( this, "PropTypes", "resource://devtools/client/shared/vendor/react-prop-types.js" ); loader.lazyRequireGetter( this, "MessageContainer", "resource://devtools/client/webconsole/components/Output/MessageContainer.js", true ); loader.lazyRequireGetter(this, "flags", "resource://devtools/shared/flags.js"); const { MESSAGE_TYPE, } = require("resource://devtools/client/webconsole/constants.js"); class ConsoleOutput extends Component { static get propTypes() { return { initialized: PropTypes.bool.isRequired, mutableMessages: PropTypes.object.isRequired, messageCount: PropTypes.number.isRequired, messagesUi: PropTypes.array.isRequired, disabledMessages: PropTypes.array.isRequired, serviceContainer: PropTypes.shape({ attachRefToWebConsoleUI: PropTypes.func.isRequired, openContextMenu: PropTypes.func.isRequired, sourceMapURLService: PropTypes.object, }), dispatch: PropTypes.func.isRequired, timestampsVisible: PropTypes.bool, cssMatchingElements: PropTypes.object.isRequired, messagesRepeat: PropTypes.object.isRequired, warningGroups: PropTypes.object.isRequired, networkMessagesUpdate: PropTypes.object.isRequired, visibleMessages: PropTypes.array.isRequired, networkMessageActiveTabId: PropTypes.string.isRequired, onFirstMeaningfulPaint: PropTypes.func.isRequired, editorMode: PropTypes.bool.isRequired, cacheGeneration: PropTypes.number.isRequired, disableVirtualization: PropTypes.bool, lastMessageId: PropTypes.string.isRequired, }; } constructor(props) { super(props); this.onContextMenu = this.onContextMenu.bind(this); this.maybeScrollToBottom = this.maybeScrollToBottom.bind(this); this.messageIdsToKeepAlive = new Set(); this.ref = createRef(); this.lazyMessageListRef = createRef(); this.resizeObserver = new ResizeObserver(() => { // If we don't have the outputNode reference, or if the outputNode isn't connected // anymore, we disconnect the resize observer (componentWillUnmount is never called // on this component, so we have to do it here). if (!this.outputNode || !this.outputNode.isConnected) { this.resizeObserver.disconnect(); return; } if (this.scrolledToBottom) { this.scrollToBottom(); } }); } componentDidMount() { if (this.props.disableVirtualization) { return; } if (this.props.visibleMessages.length) { this.scrollToBottom(); } this.scrollDetectionIntersectionObserver = new IntersectionObserver( entries => { for (const entry of entries) { // Consider that we're not pinned to the bottom anymore if the bottom of the // scrollable area is within 10px of visible (half the typical element height.) this.scrolledToBottom = entry.intersectionRatio > 0; } }, { root: this.outputNode, rootMargin: "10px" } ); this.resizeObserver.observe(this.getElementToObserve()); const { serviceContainer, onFirstMeaningfulPaint, dispatch } = this.props; serviceContainer.attachRefToWebConsoleUI( "outputScroller", this.ref.current ); // Waiting for the next paint. new Promise(res => requestAnimationFrame(res)).then(() => { if (onFirstMeaningfulPaint) { onFirstMeaningfulPaint(); } // Dispatching on next tick so we don't block on action execution. setTimeout(() => { dispatch(initialize()); }, 0); }); } // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 UNSAFE_componentWillUpdate(nextProps) { this.isUpdating = true; if (nextProps.cacheGeneration !== this.props.cacheGeneration) { this.messageIdsToKeepAlive = new Set(); } if (nextProps.editorMode !== this.props.editorMode) { this.resizeObserver.disconnect(); } const { outputNode } = this; if (!outputNode?.lastChild) { // Force a scroll to bottom when messages are added to an empty console. // This makes the console stay pinned to the bottom if a batch of messages // are added after a page refresh (Bug 1402237). this.shouldScrollBottom = true; this.scrolledToBottom = true; return; } const bottomBuffer = this.lazyMessageListRef.current.bottomBuffer; this.scrollDetectionIntersectionObserver.unobserve(bottomBuffer); const visibleMessagesDelta = nextProps.visibleMessages.length - this.props.visibleMessages.length; const messagesDelta = nextProps.messageCount - this.props.messageCount; // Evaluation results are never filtered out, so if it's in the store, it will be // visible in the output. const isNewMessageEvaluationResult = messagesDelta > 0 && nextProps.lastMessageId && nextProps.mutableMessages.get(nextProps.lastMessageId)?.type === MESSAGE_TYPE.RESULT; // Use an inline function in order to avoid executing the expensive Array.some() // unless condition are meant to do this additional check. const isOpeningGroup = () => { const messagesUiDelta = nextProps.messagesUi.length - this.props.messagesUi.length; return ( messagesUiDelta > 0 && nextProps.messagesUi.some( id => !this.props.messagesUi.includes(id) && this.props.visibleMessages.includes(id) && nextProps.visibleMessages.includes(id) ) ); }; // We need to scroll to the bottom if: this.shouldScrollBottom = // - we are reacting to "initialize" action, and we are already scrolled to the bottom (!this.props.initialized && nextProps.initialized && this.scrolledToBottom) || // - the number of messages in the store changed and the new message is an evaluation // result. isNewMessageEvaluationResult || // - the number of messages displayed changed and we are already scrolled to the // bottom, but not if we are reacting to a group opening. (this.scrolledToBottom && visibleMessagesDelta > 0 && !isOpeningGroup()); } componentDidUpdate(prevProps) { this.isUpdating = false; this.maybeScrollToBottom(); if (this?.outputNode?.lastChild) { const bottomBuffer = this.lazyMessageListRef.current.bottomBuffer; this.scrollDetectionIntersectionObserver.observe(bottomBuffer); } if (prevProps.editorMode !== this.props.editorMode) { this.resizeObserver.observe(this.getElementToObserve()); } } get outputNode() { return this.ref.current; } maybeScrollToBottom() { if (this.outputNode && this.shouldScrollBottom) { this.scrollToBottom(); } } // The maybeScrollToBottom callback we provide to messages needs to be a little bit more // strict than the one we normally use, because they can potentially interrupt a user // scroll (between when the intersection observer registers the scroll break and when // a componentDidUpdate comes through to reconcile it.) maybeScrollToBottomMessageCallback(index) { if ( this.outputNode && this.shouldScrollBottom && this.scrolledToBottom && this.lazyMessageListRef.current?.isItemNearBottom(index) ) { this.scrollToBottom(); } } scrollToBottom() { if (flags.testing && this.outputNode.hasAttribute("disable-autoscroll")) { return; } if (this.outputNode.scrollHeight > this.outputNode.clientHeight) { this.outputNode.scrollTop = this.outputNode.scrollHeight; } this.scrolledToBottom = true; } getElementToObserve() { // In inline mode, we need to observe the output node parent, which contains both the // output and the input, so we don't trigger the resizeObserver callback when only the // output size changes (e.g. when a network request is expanded). return this.props.editorMode ? this.outputNode : this.outputNode?.parentNode; } onContextMenu(e) { this.props.serviceContainer.openContextMenu(e); e.stopPropagation(); e.preventDefault(); } render() { const { cacheGeneration, dispatch, visibleMessages, disabledMessages, mutableMessages, messagesUi, cssMatchingElements, messagesRepeat, warningGroups, networkMessagesUpdate, networkMessageActiveTabId, serviceContainer, timestampsVisible, } = this.props; const renderMessage = (messageId, index) => { return createElement(MessageContainer, { dispatch, key: messageId, messageId, serviceContainer, open: messagesUi.includes(messageId), cssMatchingElements: cssMatchingElements.get(messageId), timestampsVisible, disabled: disabledMessages.includes(messageId), repeat: messagesRepeat[messageId], badge: warningGroups.has(messageId) ? warningGroups.get(messageId).length : null, inWarningGroup: warningGroups && warningGroups.size > 0 ? isMessageInWarningGroup( mutableMessages.get(messageId), visibleMessages ) : false, networkMessageUpdate: networkMessagesUpdate[messageId], networkMessageActiveTabId, getMessage: () => mutableMessages.get(messageId), maybeScrollToBottom: () => this.maybeScrollToBottomMessageCallback(index), // Whenever a node is expanded, we want to make sure we keep the // message node alive so as to not lose the expanded state. setExpanded: () => this.messageIdsToKeepAlive.add(messageId), }); }; // scrollOverdrawCount tells the list to draw extra elements above and // below the scrollport so that we can avoid flashes of blank space // when scrolling. When `disableVirtualization` is passed we make it as large as the // number of messages to render them all and effectively disabling virtualization (this // should only be used for some actions that requires all the messages to be rendered // in the DOM, like "Copy All Messages"). const scrollOverdrawCount = this.props.disableVirtualization ? visibleMessages.length : 20; const attrs = { className: "webconsole-output", role: "main", onContextMenu: this.onContextMenu, ref: this.ref, }; if (flags.testing) { attrs["data-visible-messages"] = JSON.stringify(visibleMessages); } return dom.div( attrs, createElement(LazyMessageList, { viewportRef: this.ref, items: visibleMessages, itemDefaultHeight: 21, editorMode: this.props.editorMode, scrollOverdrawCount, ref: this.lazyMessageListRef, renderItem: renderMessage, itemsToKeepAlive: this.messageIdsToKeepAlive, serviceContainer, cacheGeneration, shouldScrollBottom: () => this.shouldScrollBottom && this.isUpdating, }) ); } } function mapStateToProps(state) { const mutableMessages = getMutableMessagesById(state); return { initialized: state.ui.initialized, cacheGeneration: state.ui.cacheGeneration, // We need to compute this so lifecycle methods can compare the global message count // on state change (since we can't do it with mutableMessagesById). messageCount: mutableMessages.size, mutableMessages, lastMessageId: getLastMessageId(state), visibleMessages: getVisibleMessages(state), disabledMessages: getAllDisabledMessagesById(state), messagesUi: getAllMessagesUiById(state), cssMatchingElements: getAllCssMessagesMatchingElements(state), messagesRepeat: getAllRepeatById(state), warningGroups: getAllWarningGroupsById(state), networkMessagesUpdate: getAllNetworkMessagesUpdateById(state), timestampsVisible: state.ui.timestampsVisible, networkMessageActiveTabId: state.ui.networkMessageActiveTabId, }; } module.exports = connect(mapStateToProps)(ConsoleOutput);