Bug 1606514 - Faster visible-rows-only and pixel-aligned waterfall column r=Honza,jdescottes
authorHarald Kirschner <hkirschner@mozilla.com>
Fri, 14 Feb 2020 23:15:28 +0000
changeset 514157 10d15000963d18997b0e8d96ef4ee8a8bd4e033b
parent 514156 2aad23c07f68cda6094bc89f32ddc39087317f23
child 514158 51b0f3fef43ade362807e69e91d6786390e67015
push id37125
push usershindli@mozilla.com
push dateSat, 15 Feb 2020 09:56:17 +0000
treeherdermozilla-central@02b1aa498dd2 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersHonza, jdescottes
bugs1606514
milestone75.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1606514 - Faster visible-rows-only and pixel-aligned waterfall column r=Honza,jdescottes Part 1: Only show Waterfall only for visible rows: IntersectionObserver collects which rows are off screen as state. New elements register with the list observer and unregister later. Waterfall column is only rendered for visible rows and just an empty TD for the rest. Part 2: Scale Waterfall without transform: Waterfall isn't handled by CSS variables anymore (expensive styling fix). Differential Revision: https://phabricator.services.mozilla.com/D58476
devtools/client/netmonitor/src/assets/styles/RequestList.css
devtools/client/netmonitor/src/components/request-list/RequestListColumnWaterfall.js
devtools/client/netmonitor/src/components/request-list/RequestListContent.js
devtools/client/netmonitor/src/components/request-list/RequestListItem.js
devtools/client/netmonitor/src/selectors/ui.js
devtools/client/netmonitor/test/browser_net_brotli.js
devtools/client/netmonitor/test/browser_net_cached-status.js
devtools/client/netmonitor/test/browser_net_content-type.js
devtools/client/netmonitor/test/browser_net_cyrillic-01.js
devtools/client/netmonitor/test/browser_net_cyrillic-02.js
devtools/client/netmonitor/test/browser_net_filter-flags.js
devtools/client/netmonitor/test/browser_net_json-long.js
devtools/client/netmonitor/test/browser_net_json-malformed.js
devtools/client/netmonitor/test/browser_net_json_custom_mime.js
devtools/client/netmonitor/test/browser_net_json_text_mime.js
devtools/client/netmonitor/test/browser_net_jsonp.js
devtools/client/netmonitor/test/browser_net_large-response.js
devtools/client/netmonitor/test/browser_net_post-data-01.js
devtools/client/netmonitor/test/browser_net_req-resp-bodies.js
devtools/client/netmonitor/test/browser_net_service-worker-status.js
devtools/client/netmonitor/test/browser_net_simple-request-data.js
devtools/client/netmonitor/test/browser_net_status-codes.js
devtools/client/netmonitor/test/browser_net_streaming-response.js
devtools/client/netmonitor/test/head.js
devtools/client/webconsole/test/browser/browser_webconsole_network_messages_openinnet.js
testing/talos/talos/tests/devtools/addon/content/tests/netmonitor/netmonitor-helpers.js
--- a/devtools/client/netmonitor/src/assets/styles/RequestList.css
+++ b/devtools/client/netmonitor/src/assets/styles/RequestList.css
@@ -330,16 +330,17 @@
   margin-inline-end: 3px;
 }
 
 /* Waterfall column */
 
 .requests-list-waterfall {
   background-repeat: repeat-y;
   background-position: left center;
+  overflow: visible;
   /* Background created on a <canvas> in js. */
   /* @see devtools/client/netmonitor/src/widgets/WaterfallBackground.js */
   background-image: -moz-element(#waterfall-background);
 }
 
 .requests-list-waterfall:dir(rtl) {
   background-position: right center;
 }
@@ -390,30 +391,35 @@
 }
 
 .requests-list-timings-division[data-division-scale=second],
 .requests-list-timings-division[data-division-scale=minute] {
   font-weight: 600;
 }
 
 .requests-list-timings {
-  transform: scaleX(var(--timings-scale));
+  display: flex;
+  align-items: center;
 }
 
 .requests-list-timings:dir(ltr) {
   transform-origin: left center;
 }
 
 .requests-list-timings:dir(rtl) {
   transform-origin: right center;
 }
 
 .requests-list-timings-box {
   display: inline-block;
-  height: 9px;
+  height: 12px;
+}
+
+.requests-list-timings-box.filler {
+  background-color: var(--theme-splitter-color);
 }
 
 .requests-list-timings-box.blocked {
   background-color: var(--timing-blocked-color);
 }
 
 .requests-list-timings-box.dns {
   background-color: var(--timing-dns-color);
@@ -437,21 +443,19 @@
 
 .requests-list-timings-box.receive {
   background-color: var(--timing-receive-color);
 }
 
 .requests-list-timings-total {
   display: inline-block;
   padding-inline-start: 4px;
-  font-size: 85%;
+  font-size: 80%;
   font-weight: 600;
   white-space: nowrap;
-  /* This node should not be scaled - apply a reversed transformation */
-  transform: scaleX(var(--timings-rev-scale));
   text-align: left;
 }
 
 .requests-list-timings-total:dir(ltr) {
   transform-origin: left center;
 }
 
 .requests-list-timings-total:dir(rtl) {
--- a/devtools/client/netmonitor/src/components/request-list/RequestListColumnWaterfall.js
+++ b/devtools/client/netmonitor/src/components/request-list/RequestListColumnWaterfall.js
@@ -2,78 +2,93 @@
  * 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 } = require("devtools/client/shared/vendor/react");
 const dom = require("devtools/client/shared/vendor/react-dom-factories");
 const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+const {
+  connect,
+} = require("devtools/client/shared/redux/visibility-handler-connect");
+const {
+  getWaterfallScale,
+} = require("devtools/client/netmonitor/src/selectors/index");
 
 const { L10N } = require("devtools/client/netmonitor/src/utils/l10n");
 const {
   fetchNetworkUpdatePacket,
   propertiesEqual,
 } = require("devtools/client/netmonitor/src/utils/request-utils");
 
 // List of properties of the timing info we want to create boxes for
 const { TIMING_KEYS } = require("devtools/client/netmonitor/src/constants");
 
 const { div } = dom;
 
+const UPDATED_WATERFALL_ITEM_PROPS = ["eventTimings", "totalTime"];
 const UPDATED_WATERFALL_PROPS = [
-  "eventTimings",
-  "fromCache",
-  "fromServiceWorker",
-  "totalTime",
+  "item",
+  "firstRequestStartedMs",
+  "scale",
+  "isVisible",
 ];
 
 class RequestListColumnWaterfall extends Component {
   static get propTypes() {
     return {
       connector: PropTypes.object.isRequired,
       firstRequestStartedMs: PropTypes.number.isRequired,
       item: PropTypes.object.isRequired,
       onWaterfallMouseDown: PropTypes.func.isRequired,
+      scale: PropTypes.number,
+      isVisible: PropTypes.bool.isRequired,
     };
   }
 
+  constructor() {
+    super();
+    this.handleMouseOver = this.handleMouseOver.bind(this);
+  }
+
   componentDidMount() {
     const { connector, item } = this.props;
     fetchNetworkUpdatePacket(connector.requestData, item, ["eventTimings"]);
   }
 
   componentWillReceiveProps(nextProps) {
-    const { connector, item } = nextProps;
-    fetchNetworkUpdatePacket(connector.requestData, item, ["eventTimings"]);
+    if (nextProps.isVisible && nextProps.item.totalTime) {
+      const { connector, item } = nextProps;
+      fetchNetworkUpdatePacket(connector.requestData, item, ["eventTimings"]);
+    }
   }
 
   shouldComponentUpdate(nextProps) {
     return (
-      !propertiesEqual(
-        UPDATED_WATERFALL_PROPS,
-        this.props.item,
-        nextProps.item
-      ) || this.props.firstRequestStartedMs !== nextProps.firstRequestStartedMs
+      nextProps.isVisible &&
+      (!propertiesEqual(UPDATED_WATERFALL_PROPS, this.props, nextProps) ||
+        !propertiesEqual(
+          UPDATED_WATERFALL_ITEM_PROPS,
+          this.props.item,
+          nextProps.item
+        ))
     );
   }
 
+  handleMouseOver({ target }) {
+    if (!target.title) {
+      target.title = this.timingTooltip();
+    }
+  }
+
   timingTooltip() {
-    const {
-      eventTimings,
-      fromCache,
-      fromServiceWorker,
-      totalTime,
-    } = this.props.item;
+    const { eventTimings, totalTime } = this.props.item;
     const tooltip = [];
 
-    if (fromCache || fromServiceWorker) {
-      return tooltip;
-    }
-
     if (eventTimings) {
       for (const key of TIMING_KEYS) {
         const width = eventTimings.timings[key];
 
         if (width > 0) {
           tooltip.push(
             L10N.getFormatStr("netmonitor.waterfall.tooltip." + key, width)
           );
@@ -87,82 +102,100 @@ class RequestListColumnWaterfall extends
       );
     }
 
     return tooltip.join(L10N.getStr("netmonitor.waterfall.tooltip.separator"));
   }
 
   timingBoxes() {
     const {
-      eventTimings,
-      fromCache,
-      fromServiceWorker,
-      totalTime,
-    } = this.props.item;
+      scale,
+      item: { eventTimings, totalTime },
+    } = this.props;
     const boxes = [];
 
-    if (fromCache || fromServiceWorker) {
-      return boxes;
-    }
-
-    if (eventTimings) {
-      // Add a set of boxes representing timing information.
-      for (const key of TIMING_KEYS) {
-        const width = eventTimings.timings[key];
+    // Physical pixel as minimum size
+    const minPixel = 1 / window.devicePixelRatio;
 
-        // Don't render anything if it surely won't be visible.
-        // One millisecond == one unscaled pixel.
-        if (width > 0) {
-          boxes.push(
-            div({
-              key,
-              className: `requests-list-timings-box ${key}`,
-              style: { width },
-            })
-          );
+    if (typeof totalTime === "number") {
+      if (eventTimings) {
+        // Add a set of boxes representing timing information.
+        for (const key of TIMING_KEYS) {
+          if (eventTimings.timings[key] > 0) {
+            boxes.push(
+              div({
+                key,
+                className: `requests-list-timings-box ${key}`,
+                style: {
+                  width: Math.max(eventTimings.timings[key] * scale, minPixel),
+                },
+              })
+            );
+          }
         }
       }
-    }
+      // Minimal box to at least show start and total time
+      if (!boxes.length) {
+        boxes.push(
+          div({
+            className: "requests-list-timings-box filler",
+            key: "filler",
+            style: { width: Math.max(totalTime * scale, minPixel) },
+          })
+        );
+      }
 
-    if (typeof totalTime === "number") {
       const title = L10N.getFormatStr("networkMenu.totalMS2", totalTime);
       boxes.push(
         div(
           {
             key: "total",
             className: "requests-list-timings-total",
             title,
           },
           title
         )
       );
+    } else {
+      // Pending requests are marked for start time
+      boxes.push(
+        div({
+          className: "requests-list-timings-box filler",
+          key: "pending",
+          style: { width: minPixel },
+        })
+      );
     }
 
     return boxes;
   }
 
   render() {
-    const { firstRequestStartedMs, item, onWaterfallMouseDown } = this.props;
+    const {
+      firstRequestStartedMs,
+      item: { startedMs },
+      scale,
+      onWaterfallMouseDown,
+    } = this.props;
 
     return dom.td(
       {
         className: "requests-list-column requests-list-waterfall",
-        onMouseOver: ({ target }) => {
-          if (!target.title) {
-            target.title = this.timingTooltip();
-          }
-        },
+        onMouseOver: this.handeMouseOver,
       },
       div(
         {
           className: "requests-list-timings",
           style: {
-            paddingInlineStart: `${item.startedMs - firstRequestStartedMs}px`,
+            paddingInlineStart: `${(startedMs - firstRequestStartedMs) *
+              scale}px`,
           },
           onMouseDown: onWaterfallMouseDown,
         },
         this.timingBoxes()
       )
     );
   }
 }
 
-module.exports = RequestListColumnWaterfall;
+module.exports = connect(state => ({
+  scale: getWaterfallScale(state),
+}))(RequestListColumnWaterfall);
--- a/devtools/client/netmonitor/src/components/request-list/RequestListContent.js
+++ b/devtools/client/netmonitor/src/components/request-list/RequestListContent.js
@@ -20,17 +20,16 @@ const {
 const Actions = require("devtools/client/netmonitor/src/actions/index");
 const {
   formDataURI,
 } = require("devtools/client/netmonitor/src/utils/request-utils");
 const {
   getDisplayedRequests,
   getColumns,
   getSelectedRequest,
-  getWaterfallScale,
 } = require("devtools/client/netmonitor/src/selectors/index");
 
 loader.lazyRequireGetter(
   this,
   "openRequestInTab",
   "devtools/client/netmonitor/src/utils/firefox/open-request-in-tab",
   true
 );
@@ -86,17 +85,16 @@ class RequestListContent extends Compone
       onItemMouseDown: PropTypes.func.isRequired,
       onSecurityIconMouseDown: PropTypes.func.isRequired,
       onSelectDelta: PropTypes.func.isRequired,
       onWaterfallMouseDown: PropTypes.func.isRequired,
       openStatistics: PropTypes.func.isRequired,
       openRequestBlockingAndAddUrl: PropTypes.func.isRequired,
       openRequestBlockingAndDisableUrls: PropTypes.func.isRequired,
       removeBlockedUrl: PropTypes.func.isRequired,
-      scale: PropTypes.number,
       selectRequest: PropTypes.func.isRequired,
       selectedRequest: PropTypes.object,
       requestFilterTypes: PropTypes.object.isRequired,
     };
   }
 
   constructor(props) {
     super(props);
@@ -104,32 +102,48 @@ class RequestListContent extends Compone
     this.onScroll = this.onScroll.bind(this);
     this.onResize = this.onResize.bind(this);
     this.onKeyDown = this.onKeyDown.bind(this);
     this.openRequestInTab = this.openRequestInTab.bind(this);
     this.onDoubleClick = this.onDoubleClick.bind(this);
     this.onContextMenu = this.onContextMenu.bind(this);
     this.onMouseDown = this.onMouseDown.bind(this);
     this.hasOverflow = false;
+    this.onIntersect = this.onIntersect.bind(this);
+    this.intersectionObserver = null;
+    this.state = {
+      onscreenItems: new Set(),
+    };
   }
 
   componentWillMount() {
     this.tooltip = new HTMLTooltip(window.parent.document, { type: "arrow" });
     window.addEventListener("resize", this.onResize);
   }
 
   componentDidMount() {
     // Install event handler for displaying a tooltip
     this.tooltip.startTogglingOnHover(this.refs.scrollEl, this.onHover, {
       toggleDelay: REQUESTS_TOOLTIP_TOGGLE_DELAY,
       interactive: true,
     });
     // Install event handler to hide the tooltip on scroll
     this.refs.scrollEl.addEventListener("scroll", this.onScroll, true);
     this.onResize();
+    this.intersectionObserver = new IntersectionObserver(this.onIntersect, {
+      root: this.refs.scrollEl,
+      // Render 10% more columns for a scrolling headstart
+      rootMargin: "10%",
+    });
+    // Prime IntersectionObserver with existing entries
+    for (const item of this.refs.scrollEl.querySelectorAll(
+      ".request-list-item"
+    )) {
+      this.intersectionObserver.observe(item);
+    }
   }
 
   componentDidUpdate(prevProps) {
     const output = this.refs.scrollEl;
     if (!this.hasOverflow && output.scrollHeight > output.clientHeight) {
       output.scrollTop = output.scrollHeight;
       this.hasOverflow = true;
     }
@@ -143,28 +157,56 @@ class RequestListContent extends Compone
   }
 
   componentWillUnmount() {
     this.refs.scrollEl.removeEventListener("scroll", this.onScroll, true);
 
     // Uninstall the tooltip event handler
     this.tooltip.stopTogglingOnHover();
     window.removeEventListener("resize", this.onResize);
+    this.intersectionObserver.disconnect();
+    this.intersectionObserver = null;
   }
 
   /*
    * Removing onResize() method causes perf regression - too many repaints of the panel.
    * So it is needed in ComponentDidMount and ComponentDidUpdate. See Bug 1532914.
    */
   onResize() {
     const parent = this.refs.scrollEl.parentNode;
     this.refs.scrollEl.style.width = parent.offsetWidth + "px";
     this.refs.scrollEl.style.height = parent.offsetHeight + "px";
   }
 
+  onIntersect(entries) {
+    // Track when off screen elements moved on screen to ensure updates
+    let onscreenDidChange = false;
+    const onscreenItems = new Set(this.state.onscreenItems);
+    for (const { target, isIntersecting } of entries) {
+      const id = target.dataset.id;
+      if (isIntersecting) {
+        if (onscreenItems.add(id)) {
+          onscreenDidChange = true;
+        }
+      } else {
+        onscreenItems.delete(id);
+      }
+    }
+    if (onscreenDidChange) {
+      // Remove ids that are no longer displayed
+      const itemIds = new Set(this.props.displayedRequests.map(({ id }) => id));
+      for (const id of onscreenItems) {
+        if (!itemIds.has(id)) {
+          onscreenItems.delete(id);
+        }
+      }
+      this.setState({ onscreenItems });
+    }
+  }
+
   /**
    * The predicate used when deciding whether a popup should be shown
    * over a request item or not.
    *
    * @param Node target
    *        The element node currently being hovered.
    * @param object tooltip
    *        The current tooltip instance.
@@ -316,20 +358,20 @@ class RequestListContent extends Compone
       connector,
       columns,
       displayedRequests,
       firstRequestStartedMs,
       onCauseBadgeMouseDown,
       onSecurityIconMouseDown,
       onWaterfallMouseDown,
       requestFilterTypes,
-      scale,
       selectedRequest,
       openRequestBlockingAndAddUrl,
       openRequestBlockingAndDisableUrls,
+      networkDetailsOpen,
     } = this.props;
 
     return div(
       {
         ref: "scrollEl",
         className: "requests-list-scroll",
       },
       [
@@ -340,50 +382,46 @@ class RequestListContent extends Compone
           },
           RequestListHeader(),
           dom.tbody(
             {
               ref: "rowGroupEl",
               className: "requests-list-row-group",
               tabIndex: 0,
               onKeyDown: this.onKeyDown,
-              style: {
-                "--timings-scale": scale,
-                "--timings-rev-scale": 1 / scale,
-              },
             },
-            displayedRequests.map((item, index) =>
-              RequestListItem({
+            displayedRequests.map((item, index) => {
+              return RequestListItem({
                 blocked: !!item.blockedReason,
                 firstRequestStartedMs,
                 fromCache: item.status === "304" || item.fromCache,
-                networkDetailsOpen: this.props.networkDetailsOpen,
+                networkDetailsOpen,
                 connector,
                 columns,
                 item,
                 index,
                 isSelected: item.id === (selectedRequest && selectedRequest.id),
+                isVisible: this.state.onscreenItems.has(item.id),
                 key: item.id,
+                intersectionObserver: this.intersectionObserver,
                 onContextMenu: this.onContextMenu,
                 onDoubleClick: () => this.onDoubleClick(item),
                 onMouseDown: evt =>
                   this.onMouseDown(evt, item.id, item.channelId),
                 onCauseBadgeMouseDown: () => onCauseBadgeMouseDown(item.cause),
                 onSecurityIconMouseDown: () =>
                   onSecurityIconMouseDown(item.securityState),
-                onWaterfallMouseDown: () => onWaterfallMouseDown(),
+                onWaterfallMouseDown: onWaterfallMouseDown,
                 requestFilterTypes,
-                openRequestBlockingAndAddUrl: url =>
-                  openRequestBlockingAndAddUrl(url),
-                openRequestBlockingAndDisableUrls: url =>
-                  openRequestBlockingAndDisableUrls(url),
-              })
-            )
-          ) // end of requests-list-row-group">
-        ),
+                openRequestBlockingAndAddUrl: openRequestBlockingAndAddUrl,
+                openRequestBlockingAndDisableUrls: openRequestBlockingAndDisableUrls,
+              });
+            })
+          )
+        ), // end of requests-list-row-group">
         dom.div({
           className: "requests-list-anchor",
           key: "anchor",
         }),
       ]
     );
   }
 }
@@ -396,17 +434,16 @@ module.exports = connect(
     columns: getColumns(state),
     networkDetailsOpen: state.ui.networkDetailsOpen,
     networkDetailsWidth: state.ui.networkDetailsWidth,
     networkDetailsHeight: state.ui.networkDetailsHeight,
     clickedRequest: state.requests.clickedRequest,
     displayedRequests: getDisplayedRequests(state),
     firstRequestStartedMs: state.requests.firstStartedMs,
     selectedRequest: getSelectedRequest(state),
-    scale: getWaterfallScale(state),
     requestFilterTypes: state.filters.requestFilterTypes,
   }),
   (dispatch, props) => ({
     cloneRequest: id => dispatch(Actions.cloneRequest(id)),
     openDetailsPanelTab: () => dispatch(Actions.openNetworkDetails(true)),
     sendCustomRequest: () =>
       dispatch(Actions.sendCustomRequest(props.connector)),
     openStatistics: open =>
--- a/devtools/client/netmonitor/src/components/request-list/RequestListItem.js
+++ b/devtools/client/netmonitor/src/components/request-list/RequestListItem.js
@@ -154,18 +154,18 @@ const UPDATED_REQ_ITEM_PROPS = [
   "responseHeaders",
 ];
 
 const UPDATED_REQ_PROPS = [
   "firstRequestStartedMs",
   "index",
   "networkDetailsOpen",
   "isSelected",
+  "isVisible",
   "requestFilterTypes",
-  "waterfallWidth",
 ];
 
 /**
  * Used by render: renders the given ColumnComponent if the flag for this column
  * is set in the columns prop. The list of props are used to determine which of
  * RequestListItem's need to be passed to the ColumnComponent. Any objects contained
  * in that list are passed as props verbatim.
  */
@@ -238,35 +238,39 @@ class RequestListItem extends Component 
   static get propTypes() {
     return {
       blocked: PropTypes.bool,
       connector: PropTypes.object.isRequired,
       columns: PropTypes.object.isRequired,
       item: PropTypes.object.isRequired,
       index: PropTypes.number.isRequired,
       isSelected: PropTypes.bool.isRequired,
+      isVisible: PropTypes.bool.isRequired,
       firstRequestStartedMs: PropTypes.number.isRequired,
       fromCache: PropTypes.bool,
       networkDetailsOpen: PropTypes.bool,
       onCauseBadgeMouseDown: PropTypes.func.isRequired,
       onDoubleClick: PropTypes.func.isRequired,
       onContextMenu: PropTypes.func.isRequired,
       onFocusedNodeChange: PropTypes.func,
       onMouseDown: PropTypes.func.isRequired,
       onSecurityIconMouseDown: PropTypes.func.isRequired,
       onWaterfallMouseDown: PropTypes.func.isRequired,
       requestFilterTypes: PropTypes.object.isRequired,
-      waterfallWidth: PropTypes.number,
+      intersectionObserver: PropTypes.object,
     };
   }
 
   componentDidMount() {
     if (this.props.isSelected) {
       this.refs.listItem.focus();
     }
+    if (this.props.intersectionObserver) {
+      this.props.intersectionObserver.observe(this.refs.listItem);
+    }
 
     const { connector, item, requestFilterTypes } = this.props;
     // Filtering XHR & WS require to lazily fetch requestHeaders & responseHeaders
     if (requestFilterTypes.xhr || requestFilterTypes.ws) {
       fetchNetworkUpdatePacket(connector.requestData, item, [
         "requestHeaders",
         "responseHeaders",
       ]);
@@ -300,24 +304,31 @@ class RequestListItem extends Component 
     if (!prevProps.isSelected && this.props.isSelected) {
       this.refs.listItem.focus();
       if (this.props.onFocusedNodeChange) {
         this.props.onFocusedNodeChange();
       }
     }
   }
 
+  componentWillUnmount() {
+    if (this.props.intersectionObserver) {
+      this.props.intersectionObserver.unobserve(this.refs.listItem);
+    }
+  }
+
   render() {
     const {
       blocked,
       connector,
       columns,
       item,
       index,
       isSelected,
+      isVisible,
       firstRequestStartedMs,
       fromCache,
       onDoubleClick,
       onContextMenu,
       onMouseDown,
       onWaterfallMouseDown,
     } = this.props;
 
@@ -332,45 +343,44 @@ class RequestListItem extends Component 
         className: classList.join(" "),
         "data-id": item.id,
         tabIndex: 0,
         onContextMenu,
         onMouseDown,
         onDoubleClick,
       },
       ...COLUMN_COMPONENTS.filter(({ column }) => columns[column]).map(
-        ({ column, ColumnComponent, props: columnProps }) =>
-          column &&
-          ColumnComponent({
+        ({ column, ColumnComponent, props: columnProps }) => {
+          return ColumnComponent({
+            key: column,
             item,
-            ...(columnProps || []).reduce(
-              (acc, keyOrObject) => {
-                if (typeof keyOrObject == "string") {
-                  acc[keyOrObject] = this.props[keyOrObject];
-                } else {
-                  Object.assign(acc, keyOrObject);
-                }
-                return acc;
-              },
-              { item }
-            ),
-          })
+            ...(columnProps || []).reduce((acc, keyOrObject) => {
+              if (typeof keyOrObject == "string") {
+                acc[keyOrObject] = this.props[keyOrObject];
+              } else {
+                Object.assign(acc, keyOrObject);
+              }
+              return acc;
+            }, {}),
+          });
+        }
       ),
       ...RESPONSE_HEADERS.filter(header => columns[header]).map(header =>
         RequestListColumnResponseHeader({
           connector,
           item,
           header,
         })
       ),
       // The last column is Waterfall (aka Timeline)
       columns.waterfall &&
         RequestListColumnWaterfall({
           connector,
           firstRequestStartedMs,
           item,
           onWaterfallMouseDown,
+          isVisible,
         })
     );
   }
 }
 
 module.exports = RequestListItem;
--- a/devtools/client/netmonitor/src/selectors/ui.js
+++ b/devtools/client/netmonitor/src/selectors/ui.js
@@ -7,36 +7,43 @@
 const { createSelector } = require("devtools/client/shared/vendor/reselect");
 const {
   REQUESTS_WATERFALL,
 } = require("devtools/client/netmonitor/src/constants");
 
 const EPSILON = 0.001;
 
 const getWaterfallScale = createSelector(
-  state => state.requests,
-  state => state.timingMarkers,
-  state => state.ui,
-  (requests, timingMarkers, ui) => {
-    if (requests.firstStartedMs === +Infinity || ui.waterfallWidth === null) {
+  state => state.requests.firstStartedMs,
+  state => state.requests.lastEndedMs,
+  state => state.timingMarkers.firstDocumentDOMContentLoadedTimestamp,
+  state => state.timingMarkers.firstDocumentLoadTimestamp,
+  state => state.ui.waterfallWidth,
+  (
+    firstStartedMs,
+    lastEndedMs,
+    firstDocumentDOMContentLoadedTimestamp,
+    firstDocumentLoadTimestamp,
+    waterfallWidth
+  ) => {
+    if (firstStartedMs === +Infinity || waterfallWidth === null) {
       return null;
     }
 
     const lastEventMs = Math.max(
-      requests.lastEndedMs,
-      timingMarkers.firstDocumentDOMContentLoadedTimestamp,
-      timingMarkers.firstDocumentLoadTimestamp
+      lastEndedMs,
+      firstDocumentDOMContentLoadedTimestamp,
+      firstDocumentLoadTimestamp
     );
-    const longestWidth = lastEventMs - requests.firstStartedMs;
+    const longestWidth = lastEventMs - firstStartedMs;
 
     // Reduce 20px for the last request's requests-list-timings-total
     return Math.min(
       Math.max(
-        (ui.waterfallWidth - REQUESTS_WATERFALL.LABEL_WIDTH - 20) /
-          longestWidth,
+        (waterfallWidth - REQUESTS_WATERFALL.LABEL_WIDTH - 20) / longestWidth,
         EPSILON
       ),
       1
     );
   }
 );
 
 function getVisibleColumns(columns) {
--- a/devtools/client/netmonitor/test/browser_net_brotli.js
+++ b/devtools/client/netmonitor/test/browser_net_brotli.js
@@ -23,19 +23,21 @@ add_task(async function() {
   );
 
   store.dispatch(Actions.batchEnable(false));
 
   // Execute requests.
   await performRequests(monitor, tab, BROTLI_REQUESTS);
 
   const requestItem = document.querySelector(".request-list-item");
+  // Status code title is generated on hover
   const requestsListStatus = requestItem.querySelector(".status-code");
   EventUtils.sendMouseEvent({ type: "mouseover" }, requestsListStatus);
   await waitUntil(() => requestsListStatus.title);
+  await waitForDOMIfNeeded(requestItem, ".requests-list-timings-total");
 
   verifyRequestItemTarget(
     document,
     getDisplayedRequests(store.getState()),
     getSortedRequests(store.getState())[0],
     "GET",
     HTTPS_CONTENT_TYPE_SJS + "?fmt=br",
     {
--- a/devtools/client/netmonitor/test/browser_net_cached-status.js
+++ b/devtools/client/netmonitor/test/browser_net_cached-status.js
@@ -95,16 +95,17 @@ add_task(async function() {
 
   let index = 0;
   for (const request of REQUEST_DATA) {
     const requestItem = document.querySelectorAll(".request-list-item")[index];
     requestItem.scrollIntoView();
     const requestsListStatus = requestItem.querySelector(".status-code");
     EventUtils.sendMouseEvent({ type: "mouseover" }, requestsListStatus);
     await waitUntil(() => requestsListStatus.title);
+    await waitForDOMIfNeeded(requestItem, ".requests-list-timings-total");
 
     info("Verifying request #" + index);
     await verifyRequestItemTarget(
       document,
       getDisplayedRequests(store.getState()),
       getSortedRequests(store.getState())[index],
       request.method,
       request.uri,
--- a/devtools/client/netmonitor/test/browser_net_content-type.js
+++ b/devtools/client/netmonitor/test/browser_net_content-type.js
@@ -23,16 +23,17 @@ add_task(async function() {
   // Execute requests.
   await performRequests(monitor, tab, CONTENT_TYPE_WITHOUT_CACHE_REQUESTS);
 
   for (const requestItem of document.querySelectorAll(".request-list-item")) {
     const requestsListStatus = requestItem.querySelector(".status-code");
     requestItem.scrollIntoView();
     EventUtils.sendMouseEvent({ type: "mouseover" }, requestsListStatus);
     await waitUntil(() => requestsListStatus.title);
+    await waitForDOMIfNeeded(requestItem, ".requests-list-timings-total");
   }
 
   verifyRequestItemTarget(
     document,
     getDisplayedRequests(store.getState()),
     getSortedRequests(store.getState())[0],
     "GET",
     CONTENT_TYPE_SJS + "?fmt=xml",
--- a/devtools/client/netmonitor/test/browser_net_cyrillic-01.js
+++ b/devtools/client/netmonitor/test/browser_net_cyrillic-01.js
@@ -22,16 +22,17 @@ add_task(async function() {
   // Execute requests.
   await performRequests(monitor, tab, 1);
 
   const requestItem = document.querySelectorAll(".request-list-item")[0];
   const requestsListStatus = requestItem.querySelector(".status-code");
   requestItem.scrollIntoView();
   EventUtils.sendMouseEvent({ type: "mouseover" }, requestsListStatus);
   await waitUntil(() => requestsListStatus.title);
+  await waitForDOMIfNeeded(requestItem, ".requests-list-timings-total");
 
   verifyRequestItemTarget(
     document,
     getDisplayedRequests(store.getState()),
     getSortedRequests(store.getState())[0],
     "GET",
     CONTENT_TYPE_SJS + "?fmt=txt",
     {
--- a/devtools/client/netmonitor/test/browser_net_cyrillic-02.js
+++ b/devtools/client/netmonitor/test/browser_net_cyrillic-02.js
@@ -21,16 +21,17 @@ add_task(async function() {
   tab.linkedBrowser.reload();
   await wait;
 
   const requestItem = document.querySelectorAll(".request-list-item")[0];
   const requestsListStatus = requestItem.querySelector(".status-code");
   requestItem.scrollIntoView();
   EventUtils.sendMouseEvent({ type: "mouseover" }, requestsListStatus);
   await waitUntil(() => requestsListStatus.title);
+  await waitForDOMIfNeeded(requestItem, ".requests-list-timings-total");
 
   verifyRequestItemTarget(
     document,
     getDisplayedRequests(store.getState()),
     getSortedRequests(store.getState())[0],
     "GET",
     CYRILLIC_URL,
     {
--- a/devtools/client/netmonitor/test/browser_net_filter-flags.js
+++ b/devtools/client/netmonitor/test/browser_net_filter-flags.js
@@ -415,16 +415,17 @@ add_task(async function() {
 
     // Fake mouse over the status column only after the list is fully updated
     const requestItems = document.querySelectorAll(".request-list-item");
     for (const requestItem of requestItems) {
       requestItem.scrollIntoView();
       const requestsListStatus = requestItem.querySelector(".status-code");
       EventUtils.sendMouseEvent({ type: "mouseover" }, requestsListStatus);
       await waitUntil(() => requestsListStatus.title);
+      await waitForDOMIfNeeded(requestItem, ".requests-list-timings-total");
     }
 
     for (let i = 0; i < visibility.length; i++) {
       const shouldBeVisible = !!visibility[i];
 
       if (shouldBeVisible) {
         const { method, url, data } = EXPECTED_REQUESTS[i];
         verifyRequestItemTarget(
--- a/devtools/client/netmonitor/test/browser_net_json-long.js
+++ b/devtools/client/netmonitor/test/browser_net_json-long.js
@@ -27,16 +27,17 @@ add_task(async function() {
 
   // Execute requests.
   await performRequests(monitor, tab, 1);
 
   const requestItem = document.querySelector(".request-list-item");
   const requestsListStatus = requestItem.querySelector(".status-code");
   EventUtils.sendMouseEvent({ type: "mouseover" }, requestsListStatus);
   await waitUntil(() => requestsListStatus.title);
+  await waitForDOMIfNeeded(requestItem, ".requests-list-timings-total");
 
   verifyRequestItemTarget(
     document,
     getDisplayedRequests(store.getState()),
     getSortedRequests(store.getState())[0],
     "GET",
     CONTENT_TYPE_SJS + "?fmt=json-long",
     {
--- a/devtools/client/netmonitor/test/browser_net_json-malformed.js
+++ b/devtools/client/netmonitor/test/browser_net_json-malformed.js
@@ -22,16 +22,17 @@ add_task(async function() {
 
   // Execute requests.
   await performRequests(monitor, tab, 1);
 
   const requestItem = document.querySelector(".request-list-item");
   const requestsListStatus = requestItem.querySelector(".status-code");
   EventUtils.sendMouseEvent({ type: "mouseover" }, requestsListStatus);
   await waitUntil(() => requestsListStatus.title);
+  await waitForDOMIfNeeded(requestItem, ".requests-list-timings-total");
 
   verifyRequestItemTarget(
     document,
     getDisplayedRequests(store.getState()),
     getSortedRequests(store.getState())[0],
     "GET",
     CONTENT_TYPE_SJS + "?fmt=json-malformed",
     {
--- a/devtools/client/netmonitor/test/browser_net_json_custom_mime.js
+++ b/devtools/client/netmonitor/test/browser_net_json_custom_mime.js
@@ -22,16 +22,17 @@ add_task(async function() {
 
   // Execute requests.
   await performRequests(monitor, tab, 1);
 
   const requestItem = document.querySelector(".request-list-item");
   const requestsListStatus = requestItem.querySelector(".status-code");
   EventUtils.sendMouseEvent({ type: "mouseover" }, requestsListStatus);
   await waitUntil(() => requestsListStatus.title);
+  await waitForDOMIfNeeded(requestItem, ".requests-list-timings-total");
 
   verifyRequestItemTarget(
     document,
     getDisplayedRequests(store.getState()),
     getSortedRequests(store.getState())[0],
     "GET",
     CONTENT_TYPE_SJS + "?fmt=json-custom-mime",
     {
--- a/devtools/client/netmonitor/test/browser_net_json_text_mime.js
+++ b/devtools/client/netmonitor/test/browser_net_json_text_mime.js
@@ -23,16 +23,17 @@ add_task(async function() {
 
   // Execute requests.
   await performRequests(monitor, tab, 1);
 
   const requestItem = document.querySelector(".request-list-item");
   const requestsListStatus = requestItem.querySelector(".status-code");
   EventUtils.sendMouseEvent({ type: "mouseover" }, requestsListStatus);
   await waitUntil(() => requestsListStatus.title);
+  await waitForDOMIfNeeded(requestItem, ".requests-list-timings-total");
 
   verifyRequestItemTarget(
     document,
     getDisplayedRequests(store.getState()),
     getSortedRequests(store.getState())[0],
     "GET",
     CONTENT_TYPE_SJS + "?fmt=json-text-mime",
     {
--- a/devtools/client/netmonitor/test/browser_net_jsonp.js
+++ b/devtools/client/netmonitor/test/browser_net_jsonp.js
@@ -25,16 +25,17 @@ add_task(async function() {
   await performRequests(monitor, tab, 2);
 
   const requestItems = document.querySelectorAll(".request-list-item");
   for (const requestItem of requestItems) {
     requestItem.scrollIntoView();
     const requestsListStatus = requestItem.querySelector(".status-code");
     EventUtils.sendMouseEvent({ type: "mouseover" }, requestsListStatus);
     await waitUntil(() => requestsListStatus.title);
+    await waitForDOMIfNeeded(requestItem, ".requests-list-timings-total");
   }
 
   verifyRequestItemTarget(
     document,
     getDisplayedRequests(store.getState()),
     getSortedRequests(store.getState())[0],
     "GET",
     CONTENT_TYPE_SJS + "?fmt=jsonp&jsonp=$_0123Fun",
--- a/devtools/client/netmonitor/test/browser_net_large-response.js
+++ b/devtools/client/netmonitor/test/browser_net_large-response.js
@@ -33,16 +33,17 @@ add_task(async function() {
   });
   await wait;
 
   const requestItem = document.querySelector(".request-list-item");
   requestItem.scrollIntoView();
   const requestsListStatus = requestItem.querySelector(".status-code");
   EventUtils.sendMouseEvent({ type: "mouseover" }, requestsListStatus);
   await waitUntil(() => requestsListStatus.title);
+  await waitForDOMIfNeeded(requestItem, ".requests-list-timings-total");
 
   verifyRequestItemTarget(
     document,
     getDisplayedRequests(store.getState()),
     getSortedRequests(store.getState())[0],
     "GET",
     CONTENT_TYPE_SJS + "?fmt=html-long",
     {
--- a/devtools/client/netmonitor/test/browser_net_post-data-01.js
+++ b/devtools/client/netmonitor/test/browser_net_post-data-01.js
@@ -27,16 +27,17 @@ add_task(async function() {
   await performRequests(monitor, tab, 2);
 
   const requestItems = document.querySelectorAll(".request-list-item");
   for (const requestItem of requestItems) {
     requestItem.scrollIntoView();
     const requestsListStatus = requestItem.querySelector(".status-code");
     EventUtils.sendMouseEvent({ type: "mouseover" }, requestsListStatus);
     await waitUntil(() => requestsListStatus.title);
+    await waitForDOMIfNeeded(requestItem, ".requests-list-timings-total");
   }
 
   verifyRequestItemTarget(
     document,
     getDisplayedRequests(store.getState()),
     getSortedRequests(store.getState())[0],
     "POST",
     SIMPLE_SJS + "?foo=bar&baz=42&type=urlencoded",
--- a/devtools/client/netmonitor/test/browser_net_req-resp-bodies.js
+++ b/devtools/client/netmonitor/test/browser_net_req-resp-bodies.js
@@ -50,16 +50,17 @@ add_task(async function() {
 
   async function verifyRequest(index) {
     const requestItems = document.querySelectorAll(".request-list-item");
     for (const requestItem of requestItems) {
       requestItem.scrollIntoView();
       const requestsListStatus = requestItem.querySelector(".status-code");
       EventUtils.sendMouseEvent({ type: "mouseover" }, requestsListStatus);
       await waitUntil(() => requestsListStatus.title);
+      await waitForDOMIfNeeded(requestItem, ".requests-list-timings-total");
     }
     verifyRequestItemTarget(
       document,
       getDisplayedRequests(store.getState()),
       getSortedRequests(store.getState())[index],
       "GET",
       CONTENT_TYPE_SJS + "?fmt=json-long",
       {
--- a/devtools/client/netmonitor/test/browser_net_service-worker-status.js
+++ b/devtools/client/netmonitor/test/browser_net_service-worker-status.js
@@ -58,16 +58,17 @@ add_task(async function() {
   );
 
   const requestItems = document.querySelectorAll(".request-list-item");
   for (const requestItem of requestItems) {
     requestItem.scrollIntoView();
     const requestsListStatus = requestItem.querySelector(".status-code");
     EventUtils.sendMouseEvent({ type: "mouseover" }, requestsListStatus);
     await waitUntil(() => requestsListStatus.title);
+    await waitForDOMIfNeeded(requestItem, ".requests-list-timings-total");
   }
 
   let index = 0;
   for (const request of REQUEST_DATA) {
     const item = getSortedRequests(store.getState())[index];
 
     info(`Verifying request #${index}`);
     await verifyRequestItemTarget(
--- a/devtools/client/netmonitor/test/browser_net_simple-request-data.js
+++ b/devtools/client/netmonitor/test/browser_net_simple-request-data.js
@@ -303,16 +303,17 @@ function test() {
         "The headersSize data has an incorrect value."
       );
 
       const requestListItem = document.querySelector(".request-list-item");
       requestListItem.scrollIntoView();
       const requestsListStatus = requestListItem.querySelector(".status-code");
       EventUtils.sendMouseEvent({ type: "mouseover" }, requestsListStatus);
       await waitUntil(() => requestsListStatus.title);
+      await waitForDOMIfNeeded(requestListItem, ".requests-list-timings-total");
 
       verifyRequestItemTarget(
         document,
         getDisplayedRequests(store.getState()),
         requestItem,
         "GET",
         SIMPLE_SJS,
         {
--- a/devtools/client/netmonitor/test/browser_net_status-codes.js
+++ b/devtools/client/netmonitor/test/browser_net_status-codes.js
@@ -113,16 +113,17 @@ add_task(async function() {
    */
   async function verifyRequests() {
     const requestListItems = document.querySelectorAll(".request-list-item");
     for (const requestItem of requestListItems) {
       requestItem.scrollIntoView();
       const requestsListStatus = requestItem.querySelector(".status-code");
       EventUtils.sendMouseEvent({ type: "mouseover" }, requestsListStatus);
       await waitUntil(() => requestsListStatus.title);
+      await waitForDOMIfNeeded(requestItem, ".requests-list-timings-total");
     }
 
     info("Verifying requests contain correct information.");
     let index = 0;
     for (const request of REQUEST_DATA) {
       const item = getSortedRequests(store.getState())[index];
       requestItems[index] = item;
 
--- a/devtools/client/netmonitor/test/browser_net_streaming-response.js
+++ b/devtools/client/netmonitor/test/browser_net_streaming-response.js
@@ -34,16 +34,17 @@ add_task(async function() {
   await wait;
 
   const requestItems = document.querySelectorAll(".request-list-item");
   for (const requestItem of requestItems) {
     requestItem.scrollIntoView();
     const requestsListStatus = requestItem.querySelector(".status-code");
     EventUtils.sendMouseEvent({ type: "mouseover" }, requestsListStatus);
     await waitUntil(() => requestsListStatus.title);
+    await waitForDOMIfNeeded(requestItem, ".requests-list-timings-total");
   }
 
   REQUESTS.forEach(([fmt], i) => {
     verifyRequestItemTarget(
       document,
       getDisplayedRequests(store.getState()),
       getSortedRequests(store.getState())[i],
       "GET",
--- a/devtools/client/netmonitor/test/head.js
+++ b/devtools/client/netmonitor/test/head.js
@@ -482,34 +482,38 @@ function verifyRequestItemTarget(
   requestList,
   requestItem,
   method,
   url,
   data = {}
 ) {
   info("> Verifying: " + method + " " + url + " " + data.toSource());
 
-  const visibleIndex = requestList.indexOf(requestItem);
+  const visibleIndex = requestList.findIndex(
+    needle => needle.id === requestItem.id
+  );
 
+  isnot(visibleIndex, -1, "The requestItem exists");
   info("Visible index of item: " + visibleIndex);
 
   const {
     fuzzyUrl,
     status,
     statusText,
     cause,
     type,
     fullMimeType,
     transferred,
     size,
     time,
     displayedStatus,
   } = data;
 
   const target = document.querySelectorAll(".request-list-item")[visibleIndex];
+
   // Bug 1414981 - Request URL should not show #hash
   const unicodeUrl = getUnicodeUrl(url.split("#")[0]);
   const ORIGINAL_FILE_URL = L10N.getFormatStr(
     "netRequest.originalFileURL.tooltip",
     url
   );
   const DECODED_FILE_URL = L10N.getFormatStr(
     "netRequest.decodedFileURL.tooltip",
--- a/devtools/client/webconsole/test/browser/browser_webconsole_network_messages_openinnet.js
+++ b/devtools/client/webconsole/test/browser/browser_webconsole_network_messages_openinnet.js
@@ -52,18 +52,19 @@ add_task(async function task() {
   );
   await waitForRequestData(netmonitor.panelWin.store, ["eventTimings"], 0);
 
   // Go back to console.
   await toolbox.selectTool("webconsole");
   info("console panel open again.");
 
   // Fire an XHR request.
-  await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function() {
-    content.wrappedJSObject.testXhrGet();
+  await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function() {
+    // Ensure XHR request is completed
+    await new Promise(resolve => content.wrappedJSObject.testXhrGet(resolve));
   });
 
   const jsonUrl = TEST_PATH + JSON_TEST_URL;
   await openMessageInNetmonitor(toolbox, hud, jsonUrl);
 
   info(
     "Wait for the netmonitor headers panel to appear as it spawn RDP requests"
   );
--- a/testing/talos/talos/tests/devtools/addon/content/tests/netmonitor/netmonitor-helpers.js
+++ b/testing/talos/talos/tests/devtools/addon/content/tests/netmonitor/netmonitor-helpers.js
@@ -25,43 +25,33 @@ const { getToolbox, runTest } = require(
  */
 async function waitForAllRequestsFinished(expectedRequests) {
   let toolbox = await getToolbox();
   let window = toolbox.getCurrentPanel().panelWin;
 
   return new Promise(resolve => {
     // Explicitly waiting for specific number of requests arrived
     let payloadReady = 0;
-    let timingsUpdated = 0;
 
     function onPayloadReady(_, id) {
       payloadReady++;
       maybeResolve();
     }
 
-    function onTimingsUpdated(_, id) {
-      timingsUpdated++;
-      maybeResolve();
-    }
-
     function maybeResolve() {
       // Have all the requests finished yet?
-      if (
-        payloadReady >= expectedRequests &&
-        timingsUpdated >= expectedRequests
-      ) {
+      if (payloadReady >= expectedRequests) {
         // All requests are done - unsubscribe from events and resolve!
         window.api.off(EVENTS.PAYLOAD_READY, onPayloadReady);
-        window.api.off(EVENTS.RECEIVED_EVENT_TIMINGS, onTimingsUpdated);
-        resolve();
+        // Resolve after current frame
+        setTimeout(resolve, 1);
       }
     }
 
     window.api.on(EVENTS.PAYLOAD_READY, onPayloadReady);
-    window.api.on(EVENTS.RECEIVED_EVENT_TIMINGS, onTimingsUpdated);
   });
 }
 
 exports.waitForNetworkRequests = async function(
   label,
   toolbox,
   expectedRequests
 ) {