Bug 1462390 - Extract history from JSTerm component; r=nchevobbe
authorJan Odvarko <odvarko@gmail.com>
Thu, 31 May 2018 12:41:29 +0200
changeset 420646 2f3835d852c0b273572543898b89b1ec53a0d6d0
parent 420645 437a8d7c128abf04b0a0d2d366cc5b58f292bc87
child 420647 426ab5b84824dc046ad0796b0c4c836f33bd8bbc
push id34076
push usernerli@mozilla.com
push dateThu, 31 May 2018 21:50:41 +0000
treeherdermozilla-central@0dc7bfc7b0c1 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnchevobbe
bugs1462390
milestone62.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 1462390 - Extract history from JSTerm component; r=nchevobbe MozReview-Commit-ID: DTlW1h2ACoI
devtools/client/inspector/test/browser_inspector_menu-04-use-in-console.js
devtools/client/inspector/test/browser_inspector_menu-06-other.js
devtools/client/webconsole/actions/history.js
devtools/client/webconsole/actions/index.js
devtools/client/webconsole/actions/moz.build
devtools/client/webconsole/components/JSTerm.js
devtools/client/webconsole/constants.js
devtools/client/webconsole/reducers/history.js
devtools/client/webconsole/reducers/index.js
devtools/client/webconsole/reducers/moz.build
devtools/client/webconsole/reducers/prefs.js
devtools/client/webconsole/selectors/history.js
devtools/client/webconsole/selectors/moz.build
devtools/client/webconsole/store.js
devtools/client/webconsole/test/mochitest/browser_jsterm_autocomplete_return_key_no_selection.js
devtools/client/webconsole/test/mochitest/browser_jsterm_history.js
devtools/client/webconsole/test/mochitest/browser_jsterm_history_persist.js
devtools/client/webconsole/test/mochitest/head.js
devtools/client/webconsole/webconsole-output-wrapper.js
--- a/devtools/client/inspector/test/browser_inspector_menu-04-use-in-console.js
+++ b/devtools/client/inspector/test/browser_inspector_menu-04-use-in-console.js
@@ -40,11 +40,11 @@ add_task(async function() {
     await inspector.once("console-var-ready");
 
     is(jstermInput.value, "temp1", "second console variable is named temp1");
 
     result = await jsterm.execute();
     isnot(result.textContent.indexOf('<p id="console-var-multi">'), -1,
           "variable temp1 references correct node");
 
-    jsterm.clearHistory();
+    hud.ui.consoleOutput.dispatchClearHistory();
   }
 });
--- a/devtools/client/inspector/test/browser_inspector_menu-06-other.js
+++ b/devtools/client/inspector/test/browser_inspector_menu-06-other.js
@@ -1,13 +1,17 @@
 /* vim: set ts=2 et sw=2 tw=80: */
 /* Any copyright is dedicated to the Public Domain.
 http://creativecommons.org/publicdomain/zero/1.0/ */
 "use strict";
 
+const {
+  getHistoryEntries,
+} = require("devtools/client/webconsole/selectors/history");
+
 // Tests for menuitem functionality that doesn't fit into any specific category
 const TEST_URL = URL_ROOT + "doc_inspector_menu.html";
 add_task(async function() {
   let { inspector, toolbox, testActor } = await openInspectorForURL(TEST_URL);
   await testShowDOMProperties();
   await testDuplicateNode();
   await testDeleteNode();
   await testDeleteTextNode();
@@ -25,17 +29,19 @@ add_task(async function() {
     info("Triggering 'Show DOM Properties' and waiting for inspector open");
     showDOMPropertiesNode.click();
     await consoleOpened;
 
     let webconsoleUI = toolbox.getPanel("webconsole").hud.ui;
     let messagesAdded = webconsoleUI.once("new-messages");
     await messagesAdded;
     info("Checking if 'inspect($0)' was evaluated");
-    ok(webconsoleUI.jsterm.history[0] === "inspect($0)");
+
+    let state = webconsoleUI.consoleOutput.getStore().getState();
+    ok(getHistoryEntries(state)[0] === "inspect($0)");
     await toolbox.toggleSplitConsole();
   }
   async function testDuplicateNode() {
     info("Testing 'Duplicate Node' menu item for normal elements.");
 
     await selectNode(".duplicate", inspector);
     is((await testActor.getNumberOfElementMatches(".duplicate")), 1,
        "There should initially be 1 .duplicate node");
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/actions/history.js
@@ -0,0 +1,65 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 {
+  APPEND_TO_HISTORY,
+  CLEAR_HISTORY,
+  HISTORY_LOADED,
+  UPDATE_HISTORY_PLACEHOLDER,
+} = require("devtools/client/webconsole/constants");
+
+/**
+ * Append a new value in the history of executed expressions,
+ * or overwrite the most recent entry. The most recent entry may
+ * contain the last edited input value that was not evaluated yet.
+ */
+function appendToHistory(expression) {
+  return {
+    type: APPEND_TO_HISTORY,
+    expression: expression,
+  };
+}
+
+/**
+ * Clear the console history altogether. Note that this will not affect
+ * other consoles that are already opened (since they have their own copy),
+ * but it will reset the array for all newly-opened consoles.
+ */
+function clearHistory() {
+  return {
+    type: CLEAR_HISTORY,
+  };
+}
+
+/**
+ * Fired when the console history from previous Firefox sessions is loaded.
+ */
+function historyLoaded(entries) {
+  return {
+    type: HISTORY_LOADED,
+    entries,
+  };
+}
+
+/**
+ * Update place-holder position in the history list.
+ */
+function updatePlaceHolder(direction, expression) {
+  return {
+    type: UPDATE_HISTORY_PLACEHOLDER,
+    direction,
+    expression,
+  };
+}
+
+module.exports = {
+  appendToHistory,
+  clearHistory,
+  historyLoaded,
+  updatePlaceHolder,
+};
--- a/devtools/client/webconsole/actions/index.js
+++ b/devtools/client/webconsole/actions/index.js
@@ -6,13 +6,14 @@
 
 "use strict";
 
 const actionModules = [
   require("./filters"),
   require("./messages"),
   require("./ui"),
   require("./notifications"),
+  require("./history"),
 ];
 
 const actions = Object.assign({}, ...actionModules);
 
 module.exports = actions;
--- a/devtools/client/webconsole/actions/moz.build
+++ b/devtools/client/webconsole/actions/moz.build
@@ -1,12 +1,13 @@
 # vim: set filetype=python:
 # 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/.
 
 DevToolsModules(
     'filters.js',
+    'history.js',
     'index.js',
     'messages.js',
     'notifications.js',
     'ui.js',
 )
--- a/devtools/client/webconsole/components/JSTerm.js
+++ b/devtools/client/webconsole/components/JSTerm.js
@@ -9,71 +9,89 @@ const Services = require("Services");
 
 loader.lazyServiceGetter(this, "clipboardHelper",
                          "@mozilla.org/widget/clipboardhelper;1",
                          "nsIClipboardHelper");
 loader.lazyRequireGetter(this, "defer", "devtools/shared/defer");
 loader.lazyRequireGetter(this, "Debugger", "Debugger");
 loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter");
 loader.lazyRequireGetter(this, "AutocompletePopup", "devtools/client/shared/autocomplete-popup");
-loader.lazyRequireGetter(this, "asyncStorage", "devtools/shared/async-storage");
 loader.lazyRequireGetter(this, "PropTypes", "devtools/client/shared/vendor/react-prop-types");
 loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true);
 loader.lazyRequireGetter(this, "KeyCodes", "devtools/client/shared/keycodes", true);
 loader.lazyRequireGetter(this, "Editor", "devtools/client/sourceeditor/editor");
 
 const l10n = require("devtools/client/webconsole/webconsole-l10n");
 
-// Constants used for defining the direction of JSTerm input history navigation.
-const HISTORY_BACK = -1;
-const HISTORY_FORWARD = 1;
-
 const HELP_URL = "https://developer.mozilla.org/docs/Tools/Web_Console/Helpers";
-
-const PREF_INPUT_HISTORY_COUNT = "devtools.webconsole.inputHistoryCount";
 const PREF_AUTO_MULTILINE = "devtools.webconsole.autoMultiline";
 
 function gSequenceId() {
   return gSequenceId.n++;
 }
 gSequenceId.n = 0;
 
+// React & Redux
 const { Component } = require("devtools/client/shared/vendor/react");
 const dom = require("devtools/client/shared/vendor/react-dom-factories");
+const { connect } = require("devtools/client/shared/vendor/react-redux");
+
+// History Modules
+const {
+  getHistory,
+  getHistoryValue
+} = require("devtools/client/webconsole/selectors/history");
+const historyActions = require("devtools/client/webconsole/actions/history");
+
+// Constants used for defining the direction of JSTerm input history navigation.
+const {
+  HISTORY_BACK,
+  HISTORY_FORWARD
+} = require("devtools/client/webconsole/constants");
 
 /**
  * Create a JSTerminal (a JavaScript command line). This is attached to an
  * existing HeadsUpDisplay (a Web Console instance). This code is responsible
  * with handling command line input and code evaluation.
  *
  * @constructor
  * @param object webConsoleFrame
  *        The WebConsoleFrame object that owns this JSTerm instance.
  */
 class JSTerm extends Component {
   static get propTypes() {
     return {
+      // Append new executed expression into history list (action).
+      appendToHistory: PropTypes.func.isRequired,
+      // Remove all entries from the history list (action).
+      clearHistory: PropTypes.func.isRequired,
+      // Returns previous or next value from the history
+      // (depending on direction argument).
+      getValueFromHistory: PropTypes.func.isRequired,
+      // History of executed expression (state).
+      history: PropTypes.object.isRequired,
+      // Console object.
       hud: PropTypes.object.isRequired,
-      // Handler for clipboard 'paste' event (also used for 'drop' event).
+      // Handler for clipboard 'paste' event (also used for 'drop' event, callback).
       onPaste: PropTypes.func,
       codeMirrorEnabled: PropTypes.bool,
+      // Update position in the history after executing an expression (action).
+      updatePlaceHolder: PropTypes.func.isRequired,
     };
   }
 
   constructor(props) {
     super(props);
 
     const {
       hud,
     } = props;
 
     this.hud = hud;
     this.hudId = this.hud.hudId;
-    this.inputHistoryCount = Services.prefs.getIntPref(PREF_INPUT_HISTORY_COUNT);
-    this._loadHistory();
 
     /**
      * Stores the data for the last completion.
      * @type object
      */
     this.lastCompletion = { value: null };
 
     this._keyPress = this._keyPress.bind(this);
@@ -123,21 +141,16 @@ class JSTerm extends Component {
     /**
      * Tells if the autocomplete popup was navigated since the last open.
      *
      * @private
      * @type boolean
      */
     this._autocompletePopupNavigated = false;
 
-    /**
-     * History of code that was executed.
-     * @type array
-     */
-    this.history = [];
     this.autocompletePopup = null;
     this.inputNode = null;
     this.completeNode = null;
 
     this.COMPLETE_FORWARD = 0;
     this.COMPLETE_BACKWARD = 1;
     this.COMPLETE_HINT_ONLY = 2;
     this.COMPLETE_PAGEUP = 3;
@@ -209,73 +222,24 @@ class JSTerm extends Component {
       this.inputNode.addEventListener("focus", this._focusEventHandler);
       this.focus();
     }
 
     this.hud.window.addEventListener("blur", this._blurEventHandler);
     this.lastInputValue && this.setInputValue(this.lastInputValue);
   }
 
-  shouldComponentUpdate() {
-    // XXX: For now, everything is handled in an imperative way and we only want React
-    // to do the initial rendering of the component.
+  shouldComponentUpdate(nextProps, nextState) {
+    // XXX: For now, everything is handled in an imperative way and we
+    // only want React to do the initial rendering of the component.
     // This should be modified when the actual refactoring will take place.
     return false;
   }
 
   /**
-   * Load the console history from previous sessions.
-   * @private
-   */
-  _loadHistory() {
-    this.history = [];
-    this.historyIndex = this.historyPlaceHolder = 0;
-
-    this.historyLoaded = asyncStorage.getItem("webConsoleHistory")
-      .then(value => {
-        if (Array.isArray(value)) {
-          // Since it was gotten asynchronously, there could be items already in
-          // the history.  It's not likely but stick them onto the end anyway.
-          this.history = value.concat(this.history);
-
-          // Holds the number of entries in history. This value is incremented
-          // in this.execute().
-          this.historyIndex = this.history.length;
-
-          // Holds the index of the history entry that the user is currently
-          // viewing. This is reset to this.history.length when this.execute()
-          // is invoked.
-          this.historyPlaceHolder = this.history.length;
-        }
-      }, console.error);
-  }
-
-  /**
-   * Clear the console history altogether.  Note that this will not affect
-   * other consoles that are already opened (since they have their own copy),
-   * but it will reset the array for all newly-opened consoles.
-   * @returns Promise
-   *          Resolves once the changes have been persisted.
-   */
-  clearHistory() {
-    this.history = [];
-    this.historyIndex = this.historyPlaceHolder = 0;
-    return this.storeHistory();
-  }
-
-  /**
-   * Stores the console history for future console instances.
-   * @returns Promise
-   *          Resolves once the changes have been persisted.
-   */
-  storeHistory() {
-    return asyncStorage.setItem("webConsoleHistory", this.history);
-  }
-
-  /**
    * Getter for the element that holds the messages we display.
    * @type Element
    */
   get outputNode() {
     return this.hud.outputNode;
   }
 
   /**
@@ -323,17 +287,17 @@ class JSTerm extends Component {
     let helperHasRawOutput = !!(helperResult || {}).rawOutput;
 
     if (helperResult && helperResult.type) {
       switch (helperResult.type) {
         case "clearOutput":
           this.clearOutput();
           break;
         case "clearHistory":
-          this.clearHistory();
+          this.props.clearHistory();
           break;
         case "inspectObject":
           this.inspectObjectActor(helperResult.object);
           break;
         case "error":
           try {
             errorMessage = l10n.getStr(helperResult.message);
           } catch (ex) {
@@ -389,27 +353,19 @@ class JSTerm extends Component {
     let resultCallback = msg => deferred.resolve(msg);
 
     // attempt to execute the content of the inputNode
     executeString = executeString || this.getInputValue();
     if (!executeString) {
       return null;
     }
 
-    // Append a new value in the history of executed code, or overwrite the most
-    // recent entry. The most recent entry may contain the last edited input
-    // value that was not evaluated yet.
-    this.history[this.historyIndex++] = executeString;
-    this.historyPlaceHolder = this.history.length;
+    // Append executed expression into the history list.
+    this.props.appendToHistory(executeString);
 
-    if (this.history.length > this.inputHistoryCount) {
-      this.history.splice(0, this.history.length - this.inputHistoryCount);
-      this.historyIndex = this.historyPlaceHolder = this.history.length;
-    }
-    this.storeHistory();
     WebConsoleUtils.usageCount++;
     this.setInputValue("");
     this.clearCompletion();
 
     let selectedNodeActor = null;
     let inspectorSelection = this.hud.owner.getInspectorSelection();
     if (inspectorSelection && inspectorSelection.nodeFront) {
       selectedNodeActor = inspectorSelection.nodeFront.actorID;
@@ -887,49 +843,36 @@ class JSTerm extends Component {
    *
    * @param number direction
    *        History navigation direction: HISTORY_BACK or HISTORY_FORWARD.
    *
    * @returns boolean
    *          True if the input value changed, false otherwise.
    */
   historyPeruse(direction) {
-    if (!this.history.length) {
+    let {
+      history,
+      updatePlaceHolder,
+      getValueFromHistory,
+    } = this.props;
+
+    if (!history.entries.length) {
       return false;
     }
 
-    // Up Arrow key
-    if (direction == HISTORY_BACK) {
-      if (this.historyPlaceHolder <= 0) {
-        return false;
-      }
-      let inputVal = this.history[--this.historyPlaceHolder];
+    let newInputValue = getValueFromHistory(direction);
+    let expression = this.getInputValue();
+    updatePlaceHolder(direction, expression);
 
-      // Save the current input value as the latest entry in history, only if
-      // the user is already at the last entry.
-      // Note: this code does not store changes to items that are already in
-      // history.
-      if (this.historyPlaceHolder + 1 == this.historyIndex) {
-        this.history[this.historyIndex] = this.getInputValue() || "";
-      }
-
-      this.setInputValue(inputVal);
-    } else if (direction == HISTORY_FORWARD) {
-      // Down Arrow key
-      if (this.historyPlaceHolder >= (this.history.length - 1)) {
-        return false;
-      }
-
-      let inputVal = this.history[++this.historyPlaceHolder];
-      this.setInputValue(inputVal);
-    } else {
-      throw new Error("Invalid argument 0");
+    if (newInputValue != null) {
+      this.setInputValue(newInputValue);
+      return true;
     }
 
-    return true;
+    return false;
   }
 
   /**
    * Test for multiline input.
    *
    * @return boolean
    *         True if CR or LF found in node value; else false.
    */
@@ -1399,9 +1342,27 @@ class JSTerm extends Component {
           onPaste: onPaste,
           onDrop: onPaste,
         })
       )
     );
   }
 }
 
-module.exports = JSTerm;
+// Redux connect
+
+function mapStateToProps(state) {
+  return {
+    history: getHistory(state),
+    getValueFromHistory: (direction) => getHistoryValue(state, direction),
+  };
+}
+
+function mapDispatchToProps(dispatch) {
+  return {
+    appendToHistory: (expr) => dispatch(historyActions.appendToHistory(expr)),
+    clearHistory: () => dispatch(historyActions.clearHistory()),
+    updatePlaceHolder: (direction, expression) =>
+      dispatch(historyActions.updatePlaceHolder(direction, expression)),
+  };
+}
+
+module.exports = connect(mapStateToProps, mapDispatchToProps)(JSTerm);
--- a/devtools/client/webconsole/constants.js
+++ b/devtools/client/webconsole/constants.js
@@ -25,16 +25,20 @@ const actionTypes = {
   REMOVED_ACTORS_CLEAR: "REMOVED_ACTORS_CLEAR",
   SELECT_NETWORK_MESSAGE_TAB: "SELECT_NETWORK_MESSAGE_TAB",
   SIDEBAR_CLOSE: "SIDEBAR_CLOSE",
   SHOW_OBJECT_IN_SIDEBAR: "SHOW_OBJECT_IN_SIDEBAR",
   TIMESTAMPS_TOGGLE: "TIMESTAMPS_TOGGLE",
   APPEND_NOTIFICATION: "APPEND_NOTIFICATION",
   REMOVE_NOTIFICATION: "REMOVE_NOTIFICATION",
   SPLIT_CONSOLE_CLOSE_BUTTON_TOGGLE: "SPLIT_CONSOLE_CLOSE_BUTTON_TOGGLE",
+  APPEND_TO_HISTORY: "APPEND_TO_HISTORY",
+  CLEAR_HISTORY: "CLEAR_HISTORY",
+  HISTORY_LOADED: "HISTORY_LOADED",
+  UPDATE_HISTORY_PLACEHOLDER: "UPDATE_HISTORY_PLACEHOLDER",
 };
 
 const prefs = {
   PREFS: {
     // Filter preferences only have the suffix since they can be used either for the
     // webconsole or the browser console.
     FILTER: {
       ERROR: "filter.error",
@@ -47,16 +51,18 @@ const prefs = {
       NETXHR: "filter.netxhr",
     },
     UI: {
       // Filter bar UI preference only have the suffix since it can be used either for
       // the webconsole or the browser console.
       FILTER_BAR: "ui.filterbar",
       // Persist is only used by the webconsole.
       PERSIST: "devtools.webconsole.persistlog",
+      // Max number of entries in history list.
+      INPUT_HISTORY_COUNT: "devtools.webconsole.inputHistoryCount",
     },
     FEATURES: {
       // We use the same pref to enable the sidebar on webconsole and browser console.
       SIDEBAR_TOGGLE: "devtools.webconsole.sidebarToggle",
       JSTERM_CODE_MIRROR: "devtools.webconsole.jsterm.codeMirror",
     }
   }
 };
@@ -133,19 +139,26 @@ const chromeRDPEnums = {
 };
 
 const jstermCommands = {
   JSTERM_COMMANDS: {
     INSPECT: "inspectObject"
   }
 };
 
+// Constants used for defining the direction of JSTerm input history navigation.
+const historyCommands = {
+  HISTORY_BACK: -1,
+  HISTORY_FORWARD: 1,
+};
+
 // Combine into a single constants object
 module.exports = Object.assign({
   FILTERS,
   DEFAULT_FILTERS,
   DEFAULT_FILTERS_VALUES,
 },
   actionTypes,
   chromeRDPEnums,
   jstermCommands,
   prefs,
+  historyCommands,
 );
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/reducers/history.js
@@ -0,0 +1,126 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 {
+  APPEND_TO_HISTORY,
+  CLEAR_HISTORY,
+  HISTORY_LOADED,
+  UPDATE_HISTORY_PLACEHOLDER,
+  HISTORY_BACK,
+  HISTORY_FORWARD,
+} = require("devtools/client/webconsole/constants");
+
+/**
+ * Create default initial state for this reducer.
+ */
+function getInitialState() {
+  return {
+    // Array with history entries
+    entries: [],
+
+    // Holds the index of the history entry that the user is currently
+    // viewing. This is reset to this.history.length when APPEND_TO_HISTORY
+    // action is fired.
+    placeHolder: undefined,
+
+    // Holds the number of entries in history. This value is incremented
+    // when APPEND_TO_HISTORY action is fired and used to get previous
+    // value from the command line when the user goes backward.
+    index: 0,
+  };
+}
+
+function history(state = getInitialState(), action, prefsState) {
+  switch (action.type) {
+    case APPEND_TO_HISTORY:
+      return appendToHistory(state, prefsState, action.expression);
+    case CLEAR_HISTORY:
+      return clearHistory(state);
+    case HISTORY_LOADED:
+      return historyLoaded(state, action.entries);
+    case UPDATE_HISTORY_PLACEHOLDER:
+      return updatePlaceHolder(state, action.direction, action.expression);
+  }
+  return state;
+}
+
+function appendToHistory(state, prefsState, expression) {
+  // Clone state
+  state = {...state};
+  state.entries = [...state.entries];
+
+  // Append new expression
+  state.entries[state.index++] = expression;
+  state.placeHolder = state.entries.length;
+
+  // Remove entries if the limit is reached
+  if (state.entries.length > prefsState.historyCount) {
+    state.entries.splice(0, state.entries.length - prefsState.historyCount);
+    state.index = state.placeHolder = state.entries.length;
+  }
+
+  return state;
+}
+
+function clearHistory(state) {
+  return getInitialState();
+}
+
+/**
+ * Handling HISTORY_LOADED action that is fired when history
+ * entries created in previous Firefox session are loaded
+ * from async-storage.
+ *
+ * Loaded entries are appended before the ones that were
+ * added to the state in this session.
+ */
+function historyLoaded(state, entries) {
+  let newEntries = [...entries, ...state.entries];
+  return {
+    ...state,
+    entries: newEntries,
+    placeHolder: newEntries.length,
+    index: newEntries.length,
+  };
+}
+
+function updatePlaceHolder(state, direction, expression) {
+  // Handle UP arrow key => HISTORY_BACK
+  // Handle DOWN arrow key => HISTORY_FORWARD
+  if (direction == HISTORY_BACK) {
+    if (state.placeHolder <= 0) {
+      return state;
+    }
+
+    // Clone state
+    state = {...state};
+
+    // Save the current input value as the latest entry in history, only if
+    // the user is already at the last entry.
+    // Note: this code does not store changes to items that are already in
+    // history.
+    if (state.placeHolder == state.index) {
+      state.entries = [...state.entries];
+      state.entries[state.index] = expression || "";
+    }
+
+    state.placeHolder--;
+  } else if (direction == HISTORY_FORWARD) {
+    if (state.placeHolder >= (state.entries.length - 1)) {
+      return state;
+    }
+
+    state = {
+      ...state,
+      placeHolder: state.placeHolder + 1,
+    };
+  }
+
+  return state;
+}
+
+exports.history = history;
--- a/devtools/client/webconsole/reducers/index.js
+++ b/devtools/client/webconsole/reducers/index.js
@@ -5,16 +5,18 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 const { filters } = require("./filters");
 const { messages } = require("./messages");
 const { prefs } = require("./prefs");
 const { ui } = require("./ui");
 const { notifications } = require("./notifications");
+const { history } = require("./history");
 
 exports.reducers = {
   filters,
   messages,
   prefs,
   ui,
   notifications,
+  history,
 };
--- a/devtools/client/webconsole/reducers/moz.build
+++ b/devtools/client/webconsole/reducers/moz.build
@@ -1,13 +1,14 @@
 # vim: set filetype=python:
 # 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/.
 
 DevToolsModules(
     'filters.js',
+    'history.js',
     'index.js',
     'messages.js',
     'notifications.js',
     'prefs.js',
     'ui.js',
 )
--- a/devtools/client/webconsole/reducers/prefs.js
+++ b/devtools/client/webconsole/reducers/prefs.js
@@ -4,16 +4,17 @@
  * 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 PrefState = (overrides) => Object.freeze(Object.assign({
   logLimit: 1000,
   sidebarToggle: false,
   jstermCodeMirror: false,
+  historyCount: 50,
 }, overrides));
 
 function prefs(state = PrefState(), action) {
   return state;
 }
 
 exports.PrefState = PrefState;
 exports.prefs = prefs;
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/selectors/history.js
@@ -0,0 +1,50 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 {
+  HISTORY_BACK,
+  HISTORY_FORWARD,
+} = require("devtools/client/webconsole/constants");
+
+function getHistory(state) {
+  return state.history;
+}
+
+function getHistoryEntries(state) {
+  return state.history.entries;
+}
+
+function getHistoryValue(state, direction) {
+  if (direction == HISTORY_BACK) {
+    return getPreviousHistoryValue(state);
+  }
+  if (direction == HISTORY_FORWARD) {
+    return getNextHistoryValue(state);
+  }
+  return null;
+}
+
+function getNextHistoryValue(state) {
+  if (state.history.placeHolder < (state.history.entries.length - 1)) {
+    return state.history.entries[state.history.placeHolder + 1];
+  }
+  return null;
+}
+
+function getPreviousHistoryValue(state) {
+  if (state.history.placeHolder > 0) {
+    return state.history.entries[state.history.placeHolder - 1];
+  }
+  return null;
+}
+
+module.exports = {
+  getHistory,
+  getHistoryEntries,
+  getHistoryValue,
+};
--- a/devtools/client/webconsole/selectors/moz.build
+++ b/devtools/client/webconsole/selectors/moz.build
@@ -1,12 +1,13 @@
 # vim: set filetype=python:
 # 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/.
 
 DevToolsModules(
     'filters.js',
+    'history.js',
     'messages.js',
     'notifications.js',
     'prefs.js',
     'ui.js',
 )
--- a/devtools/client/webconsole/store.js
+++ b/devtools/client/webconsole/store.js
@@ -19,49 +19,56 @@ const {
   MESSAGES_ADD,
   MESSAGES_CLEAR,
   PRIVATE_MESSAGES_CLEAR,
   REMOVED_ACTORS_CLEAR,
   NETWORK_MESSAGE_UPDATE,
   PREFS,
   INITIALIZE,
   FILTER_TOGGLE,
+  APPEND_TO_HISTORY,
+  CLEAR_HISTORY,
 } = require("devtools/client/webconsole/constants");
 const { reducers } = require("./reducers/index");
 const {
   getMessage,
   getAllMessagesUiById,
 } = require("devtools/client/webconsole/selectors/messages");
 const DataProvider = require("devtools/client/netmonitor/src/connector/firefox-data-provider");
 const {
   getAllNetworkMessagesUpdateById,
 } = require("devtools/client/webconsole/selectors/messages");
 const {getPrefsService} = require("devtools/client/webconsole/utils/prefs");
+const historyActions = require("devtools/client/webconsole/actions/history");
+
+loader.lazyRequireGetter(this, "asyncStorage", "devtools/shared/async-storage");
 
 /**
  * Create and configure store for the Console panel. This is the place
  * where various enhancers and middleware can be registered.
  */
 function configureStore(hud, options = {}) {
   const prefsService = getPrefsService(hud);
   const {
     getBoolPref,
     getIntPref,
   } = prefsService;
 
   const logLimit = options.logLimit
     || Math.max(getIntPref("devtools.hud.loglimit"), 1);
   const sidebarToggle = getBoolPref(PREFS.FEATURES.SIDEBAR_TOGGLE);
   const jstermCodeMirror = getBoolPref(PREFS.FEATURES.JSTERM_CODE_MIRROR);
+  const historyCount = getIntPref(PREFS.UI.INPUT_HISTORY_COUNT);
 
   const initialState = {
     prefs: PrefState({
       logLimit,
       sidebarToggle,
       jstermCodeMirror,
+      historyCount,
     }),
     filters: FilterState({
       error: getBoolPref(PREFS.FILTER.ERROR),
       warn: getBoolPref(PREFS.FILTER.WARN),
       info: getBoolPref(PREFS.FILTER.INFO),
       debug: getBoolPref(PREFS.FILTER.DEBUG),
       log: getBoolPref(PREFS.FILTER.LOG),
       css: getBoolPref(PREFS.FILTER.CSS),
@@ -70,21 +77,27 @@ function configureStore(hud, options = {
     }),
     ui: UiState({
       filterBarVisible: getBoolPref(PREFS.UI.FILTER_BAR),
       networkMessageActiveTabId: "headers",
       persistLogs: getBoolPref(PREFS.UI.PERSIST),
     })
   };
 
+  // Prepare middleware.
+  let middleware = applyMiddleware(
+    thunk.bind(null, {prefsService}),
+    historyPersistenceMiddleware,
+  );
+
   return createStore(
     createRootReducer(),
     initialState,
     compose(
-      applyMiddleware(thunk.bind(null, {prefsService})),
+      middleware,
       enableActorReleaser(hud),
       enableBatching(),
       enableNetProvider(hud),
       enableMessagesCacheClearing(hud),
       ensureCSSErrorReportingEnabled(hud),
     )
   );
 }
@@ -94,24 +107,29 @@ function thunk(options = {}, { dispatch,
     return (typeof action === "function")
       ? action(dispatch, getState, options)
       : next(action);
   };
 }
 
 function createRootReducer() {
   return function rootReducer(state, action) {
-    // We want to compute the new state for all properties except "messages".
+    // We want to compute the new state for all properties except
+    // "messages" and "history". These two reducers are handled
+    // separately since they are receiving additional arguments.
     const newState = [...Object.entries(reducers)].reduce((res, [key, reducer]) => {
-      if (key !== "messages") {
+      if (key !== "messages" && key !== "history") {
         res[key] = reducer(state[key], action);
       }
       return res;
     }, {});
 
+    // Pass prefs state as additional argument to the history reducer.
+    newState.history = reducers.history(state.history, action, newState.prefs);
+
     return Object.assign(newState, {
       // specifically pass the updated filters and prefs state as additional arguments.
       messages: reducers.messages(
         state.messages,
         action,
         newState.filters,
         newState.prefs,
       ),
@@ -222,17 +240,17 @@ function enableNetProvider(hud) {
         updateRequest: (id, data, batch) => {
           proxy.dispatchRequestUpdate(id, data);
         }
       };
 
       // Data provider implements async logic for fetching
       // data from the backend. It's created the first
       // time it's needed.
-      if (!dataProvider) {
+      if (!dataProvider && proxy.webConsoleClient) {
         dataProvider = new DataProvider({
           actions,
           webConsoleClient: proxy.webConsoleClient
         });
 
         // /!\ This is terrible, but it allows ResponsePanel to be able to call
         // `dataProvider.requestData` to fetch response content lazily.
         // `proxy.networkDataProvider` is put by WebConsoleOutputWrapper on
@@ -318,12 +336,47 @@ function enableMessagesCacheClearing(hud
 function releaseActors(removedActors, proxy) {
   if (!proxy) {
     return;
   }
 
   removedActors.forEach(actor => proxy.releaseActor(actor));
 }
 
+/**
+ * History persistence middleware is responsible for loading
+ * and maintaining history of executed expressions in JSTerm.
+ */
+function historyPersistenceMiddleware(store) {
+  let historyLoaded = false;
+  asyncStorage.getItem("webConsoleHistory").then(value => {
+    if (Array.isArray(value)) {
+      store.dispatch(historyActions.historyLoaded(value));
+    }
+    historyLoaded = true;
+  }, err => {
+    historyLoaded = true;
+    console.error(err);
+  });
+
+  return next => action => {
+    const res = next(action);
+
+    let triggerStoreActions = [
+      APPEND_TO_HISTORY,
+      CLEAR_HISTORY,
+    ];
+
+    // Save the current history entries when modified, but wait till
+    // entries from the previous session are loaded.
+    if (historyLoaded && triggerStoreActions.includes(action.type)) {
+      const state = store.getState();
+      asyncStorage.setItem("webConsoleHistory", state.history.entries);
+    }
+
+    return res;
+  };
+}
+
 // Provide the store factory for test code so that each test is working with
 // its own instance.
 module.exports.configureStore = configureStore;
 
--- a/devtools/client/webconsole/test/mochitest/browser_jsterm_autocomplete_return_key_no_selection.js
+++ b/devtools/client/webconsole/test/mochitest/browser_jsterm_autocomplete_return_key_no_selection.js
@@ -11,18 +11,26 @@ const TEST_URI = `data:text/html;charset
 <head>
   <script>
     window.testBugA = "hello world";
     window.testBugB = "hello world 2";
   </script>
 </head>
 <body>bug 873250 - test pressing return with open popup, but no selection</body>`;
 
+const {
+  getHistoryEntries,
+} = require("devtools/client/webconsole/selectors/history");
+
 add_task(async function() {
-  let { jsterm } = await openNewTabAndConsole(TEST_URI);
+  const {
+    jsterm,
+    ui,
+  } = await openNewTabAndConsole(TEST_URI);
+
   const {
     autocompletePopup: popup,
     completeNode,
   } = jsterm;
 
   const onPopUpOpen = popup.once("popup-opened");
 
   info("wait for popup to show");
@@ -38,11 +46,14 @@ add_task(async function() {
   info("press Return and wait for popup to hide");
   const onPopUpClose = popup.once("popup-closed");
   executeSoon(() => EventUtils.synthesizeKey("KEY_Enter"));
   await onPopUpClose;
 
   ok(!popup.isOpen, "popup is not open after KEY_Enter");
   is(jsterm.getInputValue(), "", "inputNode is empty after KEY_Enter");
   is(completeNode.value, "", "completeNode is empty");
-  is(jsterm.history[jsterm.history.length - 1], "window.testBug",
+
+  const state = ui.consoleOutput.getStore().getState();
+  const entries = getHistoryEntries(state);
+  is(entries[entries.length - 1], "window.testBug",
      "jsterm history is correct");
 });
--- a/devtools/client/webconsole/test/mochitest/browser_jsterm_history.js
+++ b/devtools/client/webconsole/test/mochitest/browser_jsterm_history.js
@@ -3,20 +3,23 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 // Tests the console history feature accessed via the up and down arrow keys.
 
 "use strict";
 
 const TEST_URI = "data:text/html;charset=UTF-8,test";
-const HISTORY_BACK = -1;
-const HISTORY_FORWARD = 1;
 const COMMANDS = ["document", "window", "window.location"];
 
+const {
+  HISTORY_BACK,
+  HISTORY_FORWARD,
+} = require("devtools/client/webconsole/constants");
+
 add_task(async function() {
   const { jsterm } = await openNewTabAndConsole(TEST_URI);
   const { inputNode } = jsterm;
   jsterm.clearOutput();
 
   for (let command of COMMANDS) {
     info(`Executing command ${command}`);
     jsterm.setInputValue(command);
--- a/devtools/client/webconsole/test/mochitest/browser_jsterm_history_persist.js
+++ b/devtools/client/webconsole/test/mochitest/browser_jsterm_history_persist.js
@@ -9,76 +9,99 @@
 "use strict";
 
 requestLongerTimeout(2);
 
 const TEST_URI = "data:text/html;charset=utf-8,Web Console test for " +
                  "persisting history - bug 943306";
 const INPUT_HISTORY_COUNT = 10;
 
+const {
+  getHistoryEntries,
+} = require("devtools/client/webconsole/selectors/history");
+
 add_task(async function() {
   info("Setting custom input history pref to " + INPUT_HISTORY_COUNT);
   Services.prefs.setIntPref("devtools.webconsole.inputHistoryCount", INPUT_HISTORY_COUNT);
 
   // First tab: run a bunch of commands and then make sure that you can
   // navigate through their history.
   let hud1 = await openNewTabAndConsole(TEST_URI);
+  let state1 = hud1.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state1)),
+     "[]",
+     "No history on first tab initially");
+  await populateInputHistory(hud1);
 
-  is(JSON.stringify(hud1.jsterm.history), "[]", "No history on first tab initially");
-  await populateInputHistory(hud1);
-  is(JSON.stringify(hud1.jsterm.history),
+  state1 = hud1.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state1)),
      '["0","1","2","3","4","5","6","7","8","9"]',
      "First tab has populated history");
 
   // Second tab: Just make sure that you can navigate through the history
   // generated by the first tab.
   let hud2 = await openNewTabAndConsole(TEST_URI, false);
-  is(JSON.stringify(hud2.jsterm.history),
+  let state2 = hud2.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state2)),
      '["0","1","2","3","4","5","6","7","8","9"]',
      "Second tab has populated history");
   await testNavigatingHistoryInUI(hud2);
-  is(JSON.stringify(hud2.jsterm.history),
+
+  state2 = hud2.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state2)),
      '["0","1","2","3","4","5","6","7","8","9",""]',
      "An empty entry has been added in the second tab due to history perusal");
 
   // Third tab: Should have the same history as first tab, but if we run a
   // command, then the history of the first and second shouldn't be affected
   let hud3 = await openNewTabAndConsole(TEST_URI, false);
-  is(JSON.stringify(hud3.jsterm.history),
+  let state3 = hud3.ui.consoleOutput.getStore().getState();
+
+  is(JSON.stringify(getHistoryEntries(state3)),
      '["0","1","2","3","4","5","6","7","8","9"]',
      "Third tab has populated history");
 
   // Set input value separately from execute so UP arrow accurately navigates
   // history.
   hud3.jsterm.setInputValue('"hello from third tab"');
   await hud3.jsterm.execute();
 
-  is(JSON.stringify(hud1.jsterm.history),
+  state1 = hud1.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state1)),
      '["0","1","2","3","4","5","6","7","8","9"]',
      "First tab history hasn't changed due to command in third tab");
-  is(JSON.stringify(hud2.jsterm.history),
+
+  state2 = hud2.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state2)),
      '["0","1","2","3","4","5","6","7","8","9",""]',
      "Second tab history hasn't changed due to command in third tab");
-  is(JSON.stringify(hud3.jsterm.history),
+
+  state3 = hud3.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state3)),
      '["1","2","3","4","5","6","7","8","9","\\"hello from third tab\\""]',
      "Third tab has updated history (and purged the first result) after " +
      "running a command");
 
   // Fourth tab: Should have the latest command from the third tab, followed
   // by the rest of the history from the first tab.
   let hud4 = await openNewTabAndConsole(TEST_URI, false);
-  is(JSON.stringify(hud4.jsterm.history),
+  let state4 = hud4.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state4)),
      '["1","2","3","4","5","6","7","8","9","\\"hello from third tab\\""]',
      "Fourth tab has most recent history");
 
-  await hud4.jsterm.clearHistory();
-  is(JSON.stringify(hud4.jsterm.history), "[]", "Clearing history for a tab works");
+  await hud4.jsterm.props.clearHistory();
+  state4 = hud4.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state4)),
+     "[]",
+     "Clearing history for a tab works");
 
   let hud5 = await openNewTabAndConsole(TEST_URI, false);
-  is(JSON.stringify(hud5.jsterm.history), "[]",
+  let state5 = hud5.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state5)), "[]",
      "Clearing history carries over to a new tab");
 
   info("Clearing custom input history pref");
   Services.prefs.clearUserPref("devtools.webconsole.inputHistoryCount");
 });
 
 /**
  * Populate the history by running the following commands:
--- a/devtools/client/webconsole/test/mochitest/head.js
+++ b/devtools/client/webconsole/test/mochitest/head.js
@@ -65,17 +65,17 @@ registerCleanupFunction(async function()
  */
 async function openNewTabAndConsole(url, clearJstermHistory = true) {
   let toolbox = await openNewTabAndToolbox(url, "webconsole");
   let hud = toolbox.getCurrentPanel().hud;
   hud.jsterm._lazyVariablesView = false;
 
   if (clearJstermHistory) {
     // Clearing history that might have been set in previous tests.
-    await hud.jsterm.clearHistory();
+    await hud.ui.consoleOutput.dispatchClearHistory();
   }
 
   return hud;
 }
 
 /**
  * Subscribe to the store and log out stringinfied versions of messages.
  * This is a helper function for debugging, to make is easier to see what
--- a/devtools/client/webconsole/webconsole-output-wrapper.js
+++ b/devtools/client/webconsole/webconsole-output-wrapper.js
@@ -362,16 +362,20 @@ WebConsoleOutputWrapper.prototype = {
     this.setTimeoutIfNeeded();
   },
 
   batchedMessagesAdd: function(message) {
     this.queuedMessageAdds.push(message);
     this.setTimeoutIfNeeded();
   },
 
+  dispatchClearHistory: function() {
+    store.dispatch(actions.clearHistory());
+  },
+
   /**
    * Returns a Promise that resolves once any async dispatch is finally dispatched.
    */
   waitAsyncDispatches: function() {
     if (!this.throttledDispatchPromise) {
       return Promise.resolve();
     }
     return this.throttledDispatchPromise;