tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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);