summaryrefslogtreecommitdiffstats
path: root/devtools/client/webconsole/components/Output/ConsoleOutput.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/webconsole/components/Output/ConsoleOutput.js')
-rw-r--r--devtools/client/webconsole/components/Output/ConsoleOutput.js381
1 files changed, 381 insertions, 0 deletions
diff --git a/devtools/client/webconsole/components/Output/ConsoleOutput.js b/devtools/client/webconsole/components/Output/ConsoleOutput.js
new file mode 100644
index 0000000000..a2469b3b46
--- /dev/null
+++ b/devtools/client/webconsole/components/Output/ConsoleOutput.js
@@ -0,0 +1,381 @@
+/* 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(entries => {
+ // 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, nextState) {
+ 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, props) {
+ 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);