Bug 1408182 - Replace ImmutableJS by plain JS code; r=rickychien draft
authorAlexandre Poirot <poirot.alex@gmail.com>
Wed, 11 Oct 2017 17:35:09 +0200
changeset 701271 af2847e867f7a5a2fa453d6f81ca46f7e8775f35
parent 700338 dd08f8b19cc32da161811abb2f7093e0f5392e69
child 741132 bc3c123cfcd6f290ca2506f5f15fc3c7530056b7
push id90121
push userjodvarko@mozilla.com
push dateTue, 21 Nov 2017 13:19:18 +0000
reviewersrickychien
bugs1408182
milestone59.0a1
Bug 1408182 - Replace ImmutableJS by plain JS code; r=rickychien MozReview-Commit-ID: FFUNk97n4zS
devtools/client/netmonitor/.eslintrc.js
devtools/client/netmonitor/src/components/RequestListContent.js
devtools/client/netmonitor/src/components/StatisticsPanel.js
devtools/client/netmonitor/src/components/Toolbar.js
devtools/client/netmonitor/src/reducers/requests.js
devtools/client/netmonitor/src/selectors/requests.js
devtools/client/netmonitor/src/selectors/timing-markers.js
devtools/client/netmonitor/src/selectors/ui.js
devtools/client/netmonitor/test/browser_net_copy_params.js
--- a/devtools/client/netmonitor/.eslintrc.js
+++ b/devtools/client/netmonitor/.eslintrc.js
@@ -15,9 +15,15 @@ module.exports = {
   "rules": {
     // The netmonitor is being migrated to HTML and cleaned of
     // chrome-privileged code, so this rule disallows requiring chrome
     // code. Some files in the netmonitor disable this rule still. The
     // goal is to enable the rule globally on all files.
     /* eslint-disable max-len */
     "mozilla/reject-some-requires": ["error", "^(chrome|chrome:.*|resource:.*|devtools/server/.*|.*\\.jsm|devtools/shared/platform/(chome|content)/.*)$"],
   },
+
+  "parserOptions": {
+    "ecmaFeatures": {
+      experimentalObjectRestSpread: true,
+    },
+  },
 };
--- a/devtools/client/netmonitor/src/components/RequestListContent.js
+++ b/devtools/client/netmonitor/src/components/RequestListContent.js
@@ -32,17 +32,17 @@ const MAX_SCROLL_HEIGHT = 2147483647;
  * Renders the actual contents of the request list.
  */
 class RequestListContent extends Component {
   static get propTypes() {
     return {
       connector: PropTypes.object.isRequired,
       columns: PropTypes.object.isRequired,
       dispatch: PropTypes.func.isRequired,
-      displayedRequests: PropTypes.object.isRequired,
+      displayedRequests: PropTypes.array.isRequired,
       firstRequestStartedMillis: PropTypes.number.isRequired,
       fromCache: PropTypes.bool,
       onCauseBadgeMouseDown: PropTypes.func.isRequired,
       onItemMouseDown: PropTypes.func.isRequired,
       onSecurityIconMouseDown: PropTypes.func.isRequired,
       onSelectDelta: PropTypes.func.isRequired,
       onWaterfallMouseDown: PropTypes.func.isRequired,
       scale: PropTypes.number,
--- a/devtools/client/netmonitor/src/components/StatisticsPanel.js
+++ b/devtools/client/netmonitor/src/components/StatisticsPanel.js
@@ -35,17 +35,17 @@ const CHARTS_CACHE_DISABLED = L10N.getSt
  * download the different parts of your site.
  */
 class StatisticsPanel extends Component {
   static get propTypes() {
     return {
       connector: PropTypes.object.isRequired,
       closeStatistics: PropTypes.func.isRequired,
       enableRequestFilterTypeOnly: PropTypes.func.isRequired,
-      requests: PropTypes.object,
+      requests: PropTypes.array,
     };
   }
 
   constructor(props) {
     super(props);
 
     this.state = {
       isVerticalSpliter: MediaQueryList.matches,
@@ -62,17 +62,17 @@ class StatisticsPanel extends Component 
   componentWillMount() {
     this.mdnLinkContainerNodes = new Map();
   }
 
   componentDidUpdate(prevProps) {
     MediaQueryList.addListener(this.onLayoutChange);
 
     const { requests } = this.props;
-    let ready = requests && !requests.isEmpty() && requests.every((req) =>
+    let ready = requests && requests.length && requests.every((req) =>
       req.contentSize !== undefined && req.mimeType && req.responseHeaders &&
       req.status !== undefined && req.totalTime !== undefined
     );
 
     this.createChart({
       id: "primedCacheChart",
       title: CHARTS_CACHE_ENABLED,
       data: ready ? this.sanitizeChartDataSource(requests, false) : null,
--- a/devtools/client/netmonitor/src/components/Toolbar.js
+++ b/devtools/client/netmonitor/src/components/Toolbar.js
@@ -66,17 +66,17 @@ class Toolbar extends Component {
       toggleNetworkDetails: PropTypes.func.isRequired,
       enablePersistentLogs: PropTypes.func.isRequired,
       togglePersistentLogs: PropTypes.func.isRequired,
       persistentLogsEnabled: PropTypes.bool.isRequired,
       disableBrowserCache: PropTypes.func.isRequired,
       toggleBrowserCache: PropTypes.func.isRequired,
       browserCacheDisabled: PropTypes.bool.isRequired,
       toggleRequestFilterType: PropTypes.func.isRequired,
-      filteredRequests: PropTypes.object.isRequired,
+      filteredRequests: PropTypes.array.isRequired,
     };
   }
 
   constructor(props) {
     super(props);
     this.autocompleteProvider = this.autocompleteProvider.bind(this);
     this.toggleRequestFilterType = this.toggleRequestFilterType.bind(this);
     this.updatePersistentLogsEnabled = this.updatePersistentLogsEnabled.bind(this);
--- a/devtools/client/netmonitor/src/reducers/requests.js
+++ b/devtools/client/netmonitor/src/reducers/requests.js
@@ -1,84 +1,197 @@
 /* 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 I = require("devtools/client/shared/vendor/immutable");
 const {
   getUrlDetails,
   processNetworkUpdates,
 } = require("../utils/request-utils");
 const {
   ADD_REQUEST,
   CLEAR_REQUESTS,
   CLONE_SELECTED_REQUEST,
   OPEN_NETWORK_DETAILS,
   REMOVE_SELECTED_CUSTOM_REQUEST,
   SELECT_REQUEST,
   SEND_CUSTOM_REQUEST,
   TOGGLE_RECORDING,
   UPDATE_REQUEST,
 } = require("../constants");
 
-const Request = I.Record({
-  id: null,
-  // Set to true in case of a request that's being edited as part of "edit and resend"
-  isCustom: false,
-  // Request properties - at the beginning, they are unknown and are gradually filled in
-  startedMillis: undefined,
-  endedMillis: undefined,
-  method: undefined,
-  url: undefined,
-  urlDetails: undefined,
-  remotePort: undefined,
-  remoteAddress: undefined,
-  isXHR: undefined,
-  cause: undefined,
-  fromCache: undefined,
-  fromServiceWorker: undefined,
-  status: undefined,
-  statusText: undefined,
-  httpVersion: undefined,
-  securityState: undefined,
-  securityInfo: undefined,
-  mimeType: "text/plain",
-  contentSize: undefined,
-  transferredSize: undefined,
-  totalTime: undefined,
-  eventTimings: undefined,
-  headersSize: undefined,
-  // Text value is used for storing custom request query
-  // which only appears when user edit the custom requst form
-  customQueryValue: undefined,
-  requestHeaders: undefined,
-  requestHeadersFromUploadStream: undefined,
-  requestCookies: undefined,
-  requestPostData: undefined,
-  responseHeaders: undefined,
-  responseCookies: undefined,
-  responseContent: undefined,
-  responseContentAvailable: false,
-  formDataSections: undefined,
-});
+/**
+ * This structure stores list of all HTTP requests received
+ * from the backend. It's using plain JS structures to store
+ * data instead of ImmutableJS, which is performance expensive.
+ */
+function Requests() {
+  return {
+    // Map with all requests (key = actor ID, value = request object)
+    requests: mapNew(),
+    // Selected request ID
+    selectedId: null,
+    preselectedId: null,
+    // True if the monitor is recording HTTP traffic
+    recording: true,
+    // Auxiliary fields to hold requests stats
+    firstStartedMillis: +Infinity,
+    lastEndedMillis: -Infinity,
+  };
+}
+
+/**
+ * This reducer is responsible for maintaining list of request
+ * within the Network panel.
+ */
+function requestsReducer(state = Requests(), action) {
+  switch (action.type) {
+    // Appending new request into the list/map.
+    case ADD_REQUEST: {
+      let nextState = { ...state };
+
+      let newRequest = {
+        id: action.id,
+        ...action.data,
+        urlDetails: getUrlDetails(action.data.url),
+      };
+
+      nextState.requests = mapSet(state.requests, newRequest.id, newRequest);
+
+      // Update the started/ended timestamps.
+      let { startedMillis } = action.data;
+      if (startedMillis < state.firstStartedMillis) {
+        nextState.firstStartedMillis = startedMillis;
+      }
+      if (startedMillis > state.lastEndedMillis) {
+        nextState.lastEndedMillis = startedMillis;
+      }
+
+      // Select the request if it was preselected and there is no other selection.
+      if (state.preselectedId && state.preselectedId === action.id) {
+        nextState.selectedId = state.selectedId || state.preselectedId;
+        nextState.preselectedId = null;
+      }
+
+      return nextState;
+    }
+
+    // Update an existing request (with received data).
+    case UPDATE_REQUEST: {
+      let { requests, lastEndedMillis } = state;
+
+      let request = requests.get(action.id);
+      if (!request) {
+        return state;
+      }
+
+      request = {
+        ...request,
+        ...processNetworkUpdates(action.data),
+      };
+
+      return {
+        ...state,
+        requests: mapSet(state.requests, action.id, request),
+        lastEndedMillis: lastEndedMillis,
+      };
+    }
 
-const Requests = I.Record({
-  // The collection of requests (keyed by id)
-  requests: I.Map(),
-  // Selection state
-  selectedId: null,
-  preselectedId: null,
-  // Auxiliary fields to hold requests stats
-  firstStartedMillis: +Infinity,
-  lastEndedMillis: -Infinity,
-  // Recording state
-  recording: true,
-});
+    // Remove all requests in the list. Create fresh new state
+    // object, but keep value of the `recording` field.
+    case CLEAR_REQUESTS: {
+      return {
+        ...Requests(),
+        recording: state.recording,
+      };
+    }
+
+    // Select specific request.
+    case SELECT_REQUEST: {
+      return {
+        ...state,
+        selectedId: action.id,
+      };
+    }
+
+    // Clone selected request for re-send.
+    case CLONE_SELECTED_REQUEST: {
+      let { requests, selectedId } = state;
+
+      if (!selectedId) {
+        return state;
+      }
+
+      let clonedRequest = requests.get(selectedId);
+      if (!clonedRequest) {
+        return state;
+      }
+
+      let newRequest = {
+        id: clonedRequest.id + "-clone",
+        method: clonedRequest.method,
+        url: clonedRequest.url,
+        urlDetails: clonedRequest.urlDetails,
+        requestHeaders: clonedRequest.requestHeaders,
+        requestPostData: clonedRequest.requestPostData,
+        isCustom: true
+      };
+
+      return {
+        ...state,
+        requests: mapSet(requests, newRequest.id, newRequest),
+        selectedId: newRequest.id,
+      };
+    }
+
+    // Removing temporary cloned request (created for re-send, but canceled).
+    case REMOVE_SELECTED_CUSTOM_REQUEST: {
+      return closeCustomRequest(state);
+    }
+
+    // Re-sending an existing request.
+    case SEND_CUSTOM_REQUEST: {
+      // When a new request with a given id is added in future, select it immediately.
+      // where we know in advance the ID of the request, at a time when it
+      // wasn't sent yet.
+      return closeCustomRequest(state.set("preselectedId", action.id));
+    }
+
+    // Pause/resume button clicked.
+    case TOGGLE_RECORDING: {
+      return {
+        ...state,
+        recording: !state.recording,
+      };
+    }
+
+    // Side bar with request details opened.
+    case OPEN_NETWORK_DETAILS: {
+      let nextState = { ...state };
+      if (!action.open) {
+        nextState.selectedId = null;
+        return nextState;
+      }
+
+      if (!state.selectedId && !state.requests.isEmpty()) {
+        nextState.selectedId = [...state.requests.values()][0].id;
+        return nextState;
+      }
+
+      return state;
+    }
+
+    default:
+      return state;
+  }
+}
+
+// Helpers
 
 /**
  * Remove the currently selected custom request.
  */
 function closeCustomRequest(state) {
   let { requests, selectedId } = state;
 
   if (!selectedId) {
@@ -87,127 +200,49 @@ function closeCustomRequest(state) {
 
   let removedRequest = requests.get(selectedId);
 
   // Only custom requests can be removed
   if (!removedRequest || !removedRequest.isCustom) {
     return state;
   }
 
-  return state.withMutations(st => {
-    st.requests = st.requests.delete(selectedId);
-    st.selectedId = null;
-  });
+  return {
+    ...state,
+    requests: mapDelete(state.requests, selectedId),
+    selectedId: null,
+  };
 }
 
-function requestsReducer(state = new Requests(), action) {
-  switch (action.type) {
-    case ADD_REQUEST: {
-      return state.withMutations(st => {
-        let newRequest = new Request(Object.assign(
-          { id: action.id },
-          action.data,
-          { urlDetails: getUrlDetails(action.data.url) }
-        ));
-        st.requests = st.requests.set(newRequest.id, newRequest);
-
-        // Update the started/ended timestamps
-        let { startedMillis } = action.data;
-        if (startedMillis < st.firstStartedMillis) {
-          st.firstStartedMillis = startedMillis;
-        }
-        if (startedMillis > st.lastEndedMillis) {
-          st.lastEndedMillis = startedMillis;
-        }
+// Immutability helpers
+// FIXME The following helper API need refactoring, see bug 1418969.
 
-        // Select the request if it was preselected and there is no other selection
-        if (st.preselectedId && st.preselectedId === action.id) {
-          st.selectedId = st.selectedId || st.preselectedId;
-          st.preselectedId = null;
-        }
-      });
-    }
-    case CLEAR_REQUESTS: {
-      return new Requests({
-        recording: state.recording
-      });
-    }
-    case CLONE_SELECTED_REQUEST: {
-      let { requests, selectedId } = state;
-
-      if (!selectedId) {
-        return state;
-      }
-
-      let clonedRequest = requests.get(selectedId);
-      if (!clonedRequest) {
-        return state;
-      }
-
-      let newRequest = new Request({
-        id: clonedRequest.id + "-clone",
-        method: clonedRequest.method,
-        url: clonedRequest.url,
-        urlDetails: clonedRequest.urlDetails,
-        requestHeaders: clonedRequest.requestHeaders,
-        requestPostData: clonedRequest.requestPostData,
-        isCustom: true
-      });
+/**
+ * Clone an existing map.
+ */
+function mapNew(map) {
+  let newMap = new Map(map);
+  newMap.isEmpty = () => newMap.size == 0;
+  newMap.valueSeq = () => [...newMap.values()];
+  return newMap;
+}
 
-      return state.withMutations(st => {
-        st.requests = requests.set(newRequest.id, newRequest);
-        st.selectedId = newRequest.id;
-      });
-    }
-    case OPEN_NETWORK_DETAILS: {
-      if (!action.open) {
-        return state.set("selectedId", null);
-      }
-
-      if (!state.selectedId && !state.requests.isEmpty()) {
-        return state.set("selectedId", state.requests.first().id);
-      }
+/**
+ * Append new item into existing map and return new map.
+ */
+function mapSet(map, key, value) {
+  let newMap = mapNew(map);
+  return newMap.set(key, value);
+}
 
-      return state;
-    }
-    case REMOVE_SELECTED_CUSTOM_REQUEST: {
-      return closeCustomRequest(state);
-    }
-    case SELECT_REQUEST: {
-      return state.set("selectedId", action.id);
-    }
-    case SEND_CUSTOM_REQUEST: {
-      // When a new request with a given id is added in future, select it immediately.
-      // where we know in advance the ID of the request, at a time when it
-      // wasn't sent yet.
-      return closeCustomRequest(state.set("preselectedId", action.id));
-    }
-    case TOGGLE_RECORDING: {
-      return state.set("recording", !state.recording);
-    }
-    case UPDATE_REQUEST: {
-      let { requests, lastEndedMillis } = state;
-
-      let updatedRequest = requests.get(action.id);
-      if (!updatedRequest) {
-        return state;
-      }
-
-      updatedRequest = updatedRequest.withMutations(request => {
-        let values = processNetworkUpdates(action.data);
-        request = Object.assign(request, values);
-      });
-
-      return state.withMutations(st => {
-        st.requests = requests.set(updatedRequest.id, updatedRequest);
-        st.lastEndedMillis = lastEndedMillis;
-      });
-    }
-
-    default:
-      return state;
-  }
+/**
+ * Remove an item from existing map and return new map.
+ */
+function mapDelete(map, key) {
+  let newMap = mapNew(map);
+  newMap.requests.delete(key);
+  return newMap;
 }
 
 module.exports = {
   Requests,
   requestsReducer,
 };
--- a/devtools/client/netmonitor/src/selectors/requests.js
+++ b/devtools/client/netmonitor/src/selectors/requests.js
@@ -51,43 +51,54 @@ const getTypeFilterFn = createSelector(
     const matchesType = filters.requestFilterTypes.some((enabled, filter) => {
       return enabled && Filters[filter] && Filters[filter](r);
     });
     return matchesType;
   }
 );
 
 const getSortFn = createSelector(
-  state => state.requests.requests,
+  state => state.requests,
   state => state.sort,
-  (requests, sort) => {
+  ({ requests }, sort) => {
     const sorter = Sorters[sort.type || "waterfall"];
     const ascending = sort.ascending ? +1 : -1;
     return (a, b) => ascending * sortWithClones(requests, sorter, a, b);
   }
 );
 
 const getSortedRequests = createSelector(
-  state => state.requests.requests,
+  state => state.requests,
   getSortFn,
-  (requests, sortFn) => requests.valueSeq().sort(sortFn).toList()
+  ({ requests }, sortFn) => {
+    let arr = requests.valueSeq().sort(sortFn);
+    arr.get = index => arr[index];
+    arr.isEmpty = () => this.length == 0;
+    arr.size = arr.length;
+    return arr;
+  }
 );
 
 const getDisplayedRequests = createSelector(
-  state => state.requests.requests,
+  state => state.requests,
   getFilterFn,
   getSortFn,
-  (requests, filterFn, sortFn) => requests.valueSeq()
-    .filter(filterFn).sort(sortFn).toList()
+  ({ requests }, filterFn, sortFn) => {
+    let arr = requests.valueSeq().filter(filterFn).sort(sortFn);
+    arr.get = index => arr[index];
+    arr.isEmpty = () => this.length == 0;
+    arr.size = arr.length;
+    return arr;
+  }
 );
 
 const getTypeFilteredRequests = createSelector(
-  state => state.requests.requests,
+  state => state.requests,
   getTypeFilterFn,
-  (requests, filterFn) => requests.valueSeq().filter(filterFn).toList()
+  ({ requests }, filterFn) => requests.valueSeq().filter(filterFn)
 );
 
 const getDisplayedRequestsSummary = createSelector(
   getDisplayedRequests,
   state => state.requests.lastEndedMillis - state.requests.firstStartedMillis,
   (requests, totalMillis) => {
     if (requests.size == 0) {
       return { count: 0, bytes: 0, millis: 0 };
--- a/devtools/client/netmonitor/src/selectors/timing-markers.js
+++ b/devtools/client/netmonitor/src/selectors/timing-markers.js
@@ -1,13 +1,13 @@
 /* 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";
 
 function getDisplayedTimingMarker(state, marker) {
-  return state.timingMarkers.get(marker) - state.requests.get("firstStartedMillis");
+  return state.timingMarkers.get(marker) - state.requests.firstStartedMillis;
 }
 
 module.exports = {
   getDisplayedTimingMarker,
 };
--- a/devtools/client/netmonitor/src/selectors/ui.js
+++ b/devtools/client/netmonitor/src/selectors/ui.js
@@ -3,17 +3,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const { REQUESTS_WATERFALL } = require("../constants");
 const { getDisplayedRequests } = require("./requests");
 
 function isNetworkDetailsToggleButtonDisabled(state) {
-  return getDisplayedRequests(state).isEmpty();
+  return getDisplayedRequests(state).length == 0;
 }
 
 const EPSILON = 0.001;
 
 function getWaterfallScale(state) {
   const { requests, timingMarkers, ui } = state;
 
   if (requests.firstStartedMillis === +Infinity || ui.waterfallWidth === null) {
--- a/devtools/client/netmonitor/test/browser_net_copy_params.js
+++ b/devtools/client/netmonitor/test/browser_net_copy_params.js
@@ -93,18 +93,18 @@ add_task(function* () {
         "be hidden.");
   }
 
   function* testCopyPostData(index, postData) {
     // Wait for formDataSections and requestPostData state are ready in redux store
     // since copyPostData API needs to read these state.
     yield waitUntil(() => {
       let { requests } = store.getState().requests;
-      let actIDs = Object.keys(requests.toJS());
-      let { formDataSections, requestPostData } = requests.get(actIDs[index]).toJS();
+      let actIDs = [...requests.keys()];
+      let { formDataSections, requestPostData } = requests.get(actIDs[index]);
       return formDataSections && requestPostData;
     });
     EventUtils.sendMouseEvent({ type: "mousedown" },
       document.querySelectorAll(".request-list-item")[index]);
     EventUtils.sendMouseEvent({ type: "contextmenu" },
       document.querySelectorAll(".request-list-item")[index]);
     yield waitForClipboardPromise(function setup() {
       monitor.panelWin.parent.document