Bug 1462394 - Handle autocompletion data fetching and caching in Redux; r=Honza.
authorNicolas Chevobbe <nchevobbe@mozilla.com>
Mon, 12 Nov 2018 16:07:37 +0000
changeset 445892 ec65773f113702b37d003bbfb456fedb46ec19b2
parent 445891 655b8b4a0e67c347be75b454ce52a046ccc25a40
child 445893 c78efa8bed086c825bb471494cd10d06ca3423d5
push id35028
push usercsabou@mozilla.com
push dateMon, 12 Nov 2018 21:54:15 +0000
treeherdermozilla-central@05331fb8f533 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersHonza
bugs1462394
milestone65.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 1462394 - Handle autocompletion data fetching and caching in Redux; r=Honza. This patch moves all the logic we currently have baked-in JsTerm to handle the autocompletion data: - deciding to fetch from the server or the cache - handling concurrent requests - managing the cache. This is now done through dedicated Redux actions and reducers. In the JsTerm, where the autocompletePopup still lives, we handle those data changes in componentWillReceiveProps. Some tests were modified in order to pass with these changes. Differential Revision: https://phabricator.services.mozilla.com/D11454
devtools/client/webconsole/actions/autocomplete.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/autocomplete.js
devtools/client/webconsole/reducers/index.js
devtools/client/webconsole/reducers/moz.build
devtools/client/webconsole/selectors/autocomplete.js
devtools/client/webconsole/selectors/moz.build
devtools/client/webconsole/test/mochitest/browser_jsterm_autocomplete_native_getters.js
testing/talos/talos/tests/devtools/addon/content/tests/webconsole/autocomplete.js
testing/talos/talos/tests/devtools/addon/content/tests/webconsole/typing.js
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/actions/autocomplete.js
@@ -0,0 +1,133 @@
+/* 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 {
+  AUTOCOMPLETE_CLEAR,
+  AUTOCOMPLETE_DATA_RECEIVE,
+  AUTOCOMPLETE_PENDING_REQUEST,
+  AUTOCOMPLETE_RETRIEVE_FROM_CACHE,
+} = require("devtools/client/webconsole/constants");
+
+/**
+ * Update the data used for the autocomplete popup in the console input (JsTerm).
+ *
+ * @param {Object} Object of the following shape:
+ *        - {String} inputValue: the expression to complete.
+ *        - {Int} cursor: The position of the cursor in the inputValue.
+ *        - {WebConsoleClient} client: The webconsole client.
+ *        - {String} frameActorId: The id of the frame we want to autocomplete in.
+ *        - {Boolean} force: True to force a call to the server (as opposed to retrieve
+ *                           from the cache).
+ */
+function autocompleteUpdate({
+  inputValue,
+  cursor,
+  client,
+  frameActorId,
+  force,
+}) {
+  return ({dispatch, getState}) => {
+    const {cache} = getState().autocomplete;
+
+    if (!force && (
+      !inputValue ||
+      /^[a-zA-Z0-9_$]/.test(inputValue.substring(cursor))
+    )) {
+      return dispatch(autocompleteClear());
+    }
+
+    const input = inputValue.substring(0, cursor);
+    const retrieveFromCache = !force &&
+      cache &&
+      cache.input &&
+      input.startsWith(cache.input) &&
+      /[a-zA-Z0-9]$/.test(input) &&
+      frameActorId === cache.frameActorId;
+
+    if (retrieveFromCache) {
+      return dispatch(autoCompleteDataRetrieveFromCache(input));
+    }
+
+    return dispatch(autocompleteDataFetch({input, frameActorId, client}));
+  };
+}
+
+/**
+ * Called when the autocompletion data should be cleared.
+ */
+function autocompleteClear() {
+  return {
+    type: AUTOCOMPLETE_CLEAR,
+  };
+}
+
+/**
+ * Called when the autocompletion data should be retrieved from the cache (i.e.
+ * client-side).
+ *
+ * @param {String} input: The input used to filter the cached data.
+ */
+function autoCompleteDataRetrieveFromCache(input) {
+  return {
+    type: AUTOCOMPLETE_RETRIEVE_FROM_CACHE,
+    input,
+  };
+}
+
+let currentRequestId = 0;
+function generateRequestId() {
+  return currentRequestId++;
+}
+
+/**
+ * Action that fetch autocompletion data from the server.
+ *
+ * @param {Object} Object of the following shape:
+ *        - {String} input: the expression that we want to complete.
+ *        - {String} frameActorId: The id of the frame we want to autocomplete in.
+ *        - {WebConsoleClient} client: The webconsole client.
+ */
+function autocompleteDataFetch({
+  input,
+  frameActorId,
+  client,
+}) {
+  return ({dispatch}) => {
+    const id = generateRequestId();
+    dispatch({type: AUTOCOMPLETE_PENDING_REQUEST, id});
+    client.autocomplete(input, undefined, frameActorId).then(res => {
+      dispatch(autocompleteDataReceive(id, input, frameActorId, res));
+    }).catch(e => {
+      console.error("failed autocomplete", e);
+      dispatch(autocompleteClear());
+    });
+  };
+}
+
+/**
+ * Called when we receive the autocompletion data from the server.
+ *
+ * @param {Integer} id: The autocompletion request id. This will be used in the reducer to
+ *                      check that we update the state with the last request results.
+ * @param {String} input: The expression that was evaluated to get the data.
+ *        - {String} frameActorId: The id of the frame the evaluation was made in.
+ * @param {Object} data: The actual data sent from the server.
+ */
+function autocompleteDataReceive(id, input, frameActorId, data) {
+  return {
+    type: AUTOCOMPLETE_DATA_RECEIVE,
+    id,
+    input,
+    frameActorId,
+    data,
+  };
+}
+
+module.exports = {
+  autocompleteUpdate,
+  autocompleteDataFetch,
+  autocompleteDataReceive,
+};
--- a/devtools/client/webconsole/actions/index.js
+++ b/devtools/client/webconsole/actions/index.js
@@ -2,16 +2,17 @@
 /* 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 actionModules = [
+  require("./autocomplete"),
   require("./filters"),
   require("./messages"),
   require("./ui"),
   require("./notifications"),
   require("./history"),
 ];
 
 const actions = Object.assign({}, ...actionModules);
--- a/devtools/client/webconsole/actions/moz.build
+++ b/devtools/client/webconsole/actions/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(
+    'autocomplete.js',
     '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
@@ -34,17 +34,19 @@ const { Component } = require("devtools/
 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 {getAutocompleteState} = require("devtools/client/webconsole/selectors/autocomplete");
 const historyActions = require("devtools/client/webconsole/actions/history");
+const autocompleteActions = require("devtools/client/webconsole/actions/autocomplete");
 
 // Constants used for defining the direction of JSTerm input history navigation.
 const {
   HISTORY_BACK,
   HISTORY_FORWARD,
 } = require("devtools/client/webconsole/constants");
 
 /**
@@ -72,16 +74,20 @@ class JSTerm extends Component {
       hud: PropTypes.object.isRequired,
       // Needed for opening context menu
       serviceContainer: PropTypes.object.isRequired,
       // 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).
       updateHistoryPosition: PropTypes.func.isRequired,
+      // Update autocomplete popup state.
+      autocompleteUpdate: PropTypes.func.isRequired,
+      // Data to be displayed in the autocomplete popup.
+      autocompleteData: PropTypes.object.isRequired,
     };
   }
 
   constructor(props) {
     super(props);
 
     const {
       hud,
@@ -89,50 +95,26 @@ class JSTerm extends Component {
 
     this.hud = hud;
     this.hudId = this.hud.hudId;
 
     this._keyPress = this._keyPress.bind(this);
     this._inputEventHandler = this._inputEventHandler.bind(this);
     this._blurEventHandler = this._blurEventHandler.bind(this);
     this.onContextMenu = this.onContextMenu.bind(this);
+    this.imperativeUpdate = this.imperativeUpdate.bind(this);
 
     this.SELECTED_FRAME = -1;
 
     /**
-     * Array that caches the user input suggestions received from the server.
-     * @private
-     * @type array
-     */
-    this._autocompleteCache = null;
-
-    /**
-     * The input that caused the last request to the server, whose response is
-     * cached in the _autocompleteCache array.
-     * @private
-     * @type string
-     */
-    this._autocompleteQuery = null;
-
-    /**
-     * The frameActorId used in the last autocomplete query. Whenever this changes
-     * the autocomplete cache must be invalidated.
-     * @private
-     * @type string
-     */
-    this._lastFrameActorId = null;
-
-    /**
      * Last input value.
      * @type string
      */
     this.lastInputValue = "";
 
-    this.currentAutoCompletionRequestId = null;
-
     this.autocompletePopup = null;
     this.inputNode = null;
     this.completeNode = null;
 
     this._telemetry = new Telemetry();
 
     EventEmitter.decorate(this);
     hud.jsterm = this;
@@ -351,17 +333,17 @@ class JSTerm extends Component {
                 return null;
               }
 
               return "CodeMirror.Pass";
             },
 
             "Ctrl-Space": () => {
               if (!this.autocompletePopup.isOpen) {
-                this.updateAutocompletion(true);
+                this.fetchAutocompletionProperties(true);
                 return null;
               }
 
               return "CodeMirror.Pass";
             },
 
             "Esc": false,
             "Cmd-F": false,
@@ -398,24 +380,43 @@ class JSTerm extends Component {
     // Update the character and chevron width needed for the popup offset calculations.
     this._inputCharWidth = this._getInputCharWidth();
     this._paddingInlineStart = this.editor ? null : this._getInputPaddingInlineStart();
 
     this.hud.window.addEventListener("blur", this._blurEventHandler);
     this.lastInputValue && this.setInputValue(this.lastInputValue);
   }
 
-  shouldComponentUpdate(nextProps, nextState) {
+  componentWillReceiveProps(nextProps) {
+    this.imperativeUpdate(nextProps);
+  }
+
+  shouldComponentUpdate(nextProps) {
     // 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;
   }
 
   /**
+   * Do all the imperative work needed after a Redux store update.
+   *
+   * @param {Object} nextProps: props passed from shouldComponentUpdate.
+   */
+  imperativeUpdate(nextProps) {
+    if (
+      nextProps &&
+      nextProps.autocompleteData !== this.props.autocompleteData &&
+      nextProps.autocompleteData.pendingRequestId === null
+    ) {
+      this.updateAutocompletionPopup(nextProps.autocompleteData);
+    }
+  }
+
+  /**
    * Getter for the element that holds the messages we display.
    * @type Element
    */
   get outputNode() {
     return this.hud.outputNode;
   }
 
   /**
@@ -768,17 +769,17 @@ class JSTerm extends Component {
   /**
    * The inputNode "input" and "keyup" event handler.
    * @private
    */
   _inputEventHandler() {
     const value = this.getInputValue();
     if (this.lastInputValue !== value) {
       this.resizeInput();
-      this.updateAutocompletion();
+      this.fetchAutocompletionProperties();
       this.lastInputValue = value;
     }
   }
 
   /**
    * The window "blur" event handler.
    * @private
    */
@@ -855,17 +856,17 @@ class JSTerm extends Component {
           this.clearCompletion();
           break;
         default:
           break;
       }
 
       if (event.key === " " && !this.autocompletePopup.isOpen) {
         // Open the autocompletion popup on Ctrl-Space (if it wasn't displayed).
-        this.updateAutocompletion(true);
+        this.fetchAutocompletionProperties(true);
         event.preventDefault();
       }
 
       return;
     } else if (event.keyCode == KeyCodes.DOM_VK_RETURN) {
       if (!this.autocompletePopup.isOpen && (
         event.shiftKey || !Debugger.isCompilableUnit(this.getInputValue())
       )) {
@@ -1099,147 +1100,74 @@ class JSTerm extends Component {
       return false;
     }
 
     return node.selectionStart == node.value.length ? true :
            node.selectionStart == 0 && !multiline;
   }
 
   /**
+   * Retrieves properties maching the current input for the selected frame, either from
+   * the server or from a cache if possible.
+   * Will bail-out if there's some text selection in the input.
    *
    * @param {Boolean} force: True to not perform any check before trying to show the
    *                         autocompletion popup. Defaults to false.
+   * @fires autocomplete-updated
+   * @returns void
    */
-  async updateAutocompletion(force = false) {
+  async fetchAutocompletionProperties(force = false) {
     const inputValue = this.getInputValue();
-    const {editor, inputNode} = this;
-    const frameActor = this.getFrameActor(this.SELECTED_FRAME);
-
+    const frameActorId = this.getFrameActor(this.SELECTED_FRAME);
     const cursor = this.getSelectionStart();
 
-    // Complete if:
-    // - `force` is true OR
-    //   - The input is not empty
-    //   - AND there is not text selected
-    //   - AND the input or frameActor are different from previous completion
-    //   - AND there is not an alphanumeric (+ "_" and "$") right after the cursor
-    if (!force && (
-      !inputValue ||
+    const {editor, inputNode} = this;
+    if (
       (inputNode && inputNode.selectionStart != inputNode.selectionEnd) ||
-      (editor && editor.getSelection()) ||
-      (
-        !force &&
-        this.lastInputValue === inputValue &&
-        frameActor === this._lastFrameActorId
-      ) ||
-      /^[a-zA-Z0-9_$]/.test(inputValue.substring(cursor))
-    )) {
+      (editor && editor.getSelection())
+    ) {
       this.clearCompletion();
       this.emit("autocomplete-updated");
       return;
     }
 
-    const input = this.getInputValueBeforeCursor();
-
-    // If the current input starts with the previous input, then we already
-    // have a list of suggestions and we just need to filter the cached
-    // suggestions. When the current input ends with a non-alphanumeric character we ask
-    // the server again for suggestions.
-
-    // Check if last character is non-alphanumeric
-    if (!/[a-zA-Z0-9]$/.test(input) || frameActor != this._lastFrameActorId) {
-      this._autocompleteQuery = null;
-      this._autocompleteCache = null;
-    }
-
-    if (this._autocompleteQuery && input.startsWith(this._autocompleteQuery)) {
-      let filterBy = input;
-      if (this._autocompleteCache.isElementAccess) {
-        // if we're performing an element access, we can simply retrieve whatever comes
-        // after the last opening bracket.
-        filterBy = input.substring(input.lastIndexOf("[") + 1);
-      } else {
-        // Find the last non-alphanumeric other than "_", ":", or "$" if it exists.
-        const lastNonAlpha = input.match(/[^a-zA-Z0-9_$:][a-zA-Z0-9_$:]*$/);
-        // If input contains non-alphanumerics, use the part after the last one
-        // to filter the cache.
-        if (lastNonAlpha) {
-          filterBy = input.substring(input.lastIndexOf(lastNonAlpha) + 1);
-        }
-      }
-
-      const stripWrappingQuotes = s => s.replace(/^['"`](.+(?=['"`]$))['"`]$/g, "$1");
-      const filterByLc = filterBy.toLocaleLowerCase();
-      const looseMatching = !filterBy || filterBy[0].toLocaleLowerCase() === filterBy[0];
-      const needStripQuote = this._autocompleteCache.isElementAccess
-        && !/^[`"']/.test(filterBy);
-      const newList = this._autocompleteCache.matches.filter(l => {
-        if (needStripQuote) {
-          l = stripWrappingQuotes(l);
-        }
-
-        if (looseMatching) {
-          return l.toLocaleLowerCase().startsWith(filterByLc);
-        }
-
-        return l.startsWith(filterBy);
-      });
-
-      this._receiveAutocompleteProperties(null, {
-        matches: newList,
-        matchProp: filterBy,
-        isElementAccess: this._autocompleteCache.isElementAccess,
-      });
-      return;
-    }
-    const requestId = gSequenceId();
-    this._lastFrameActorId = frameActor;
-    this.currentAutoCompletionRequestId = requestId;
-
-    const message = await this.webConsoleClient.autocomplete(input, cursor, frameActor);
-    this._receiveAutocompleteProperties(requestId, message);
+    this.props.autocompleteUpdate({
+      inputValue,
+      cursor,
+      frameActorId,
+      force,
+      client: this.webConsoleClient,
+    });
   }
 
   /**
-   * Handler for the autocompletion results. This method takes
-   * the completion result received from the server and updates the UI
-   * accordingly.
+   * Takes the data returned by the server and update the autocomplete popup state (i.e.
+   * its visibility and items).
    *
-   * @param number requestId
-   *        Request ID.
-   * @param object message
-   *        The JSON message which holds the completion results received from
-   *        the content process.
+   * @param {Object} data
+   *        The autocompletion data as returned by the webconsole actor's autocomplete
+   *        service. Should be of the following shape:
+   *        {
+   *          matches: {Array} array of the properties matching the input,
+   *          matchProp: {String} The string used to filter the properties,
+   *          isElementAccess: {Boolean} True when the input is an element access,
+   *                           i.e. `document["addEve`.
+   *        }
+   * @fires autocomplete-updated
    */
-  _receiveAutocompleteProperties(requestId, message) {
-    if (this.currentAutoCompletionRequestId !== requestId) {
-      return;
-    }
-    this.currentAutoCompletionRequestId = null;
-
-    // Cache whatever came from the server if the last char is
-    // alphanumeric, '.' or '['.
-    const inputUntilCursor = this.getInputValueBeforeCursor();
-
-    if (requestId != null && /[a-zA-Z0-9.\[]$/.test(inputUntilCursor)) {
-      this._autocompleteCache = {
-        matches: message.matches,
-        matchProp: message.matchProp,
-        isElementAccess: message.isElementAccess,
-      };
-      this._autocompleteQuery = inputUntilCursor;
-    }
-
-    const {matches, matchProp, isElementAccess} = message;
+  updateAutocompletionPopup(data) {
+    const {matches, matchProp, isElementAccess} = data;
     if (!matches.length) {
       this.clearCompletion();
       this.emit("autocomplete-updated");
       return;
     }
 
+    const inputUntilCursor = this.getInputValueBeforeCursor();
+
     const items = matches.map(label => {
       let preLabel = label.substring(0, matchProp.length);
       // If the user is performing an element access, and if they did not typed a quote,
       // then we need to adjust the preLabel to match the quote from the label + what
       // the user entered.
       if (isElementAccess && /^['"`]/.test(matchProp) === false) {
         preLabel = label.substring(0, matchProp.length + 1);
       }
@@ -1668,21 +1596,33 @@ class JSTerm extends Component {
 }
 
 // Redux connect
 
 function mapStateToProps(state) {
   return {
     history: getHistory(state),
     getValueFromHistory: (direction) => getHistoryValue(state, direction),
+    autocompleteData: getAutocompleteState(state),
   };
 }
 
 function mapDispatchToProps(dispatch) {
   return {
+
     appendToHistory: (expr) => dispatch(historyActions.appendToHistory(expr)),
     clearHistory: () => dispatch(historyActions.clearHistory()),
     updateHistoryPosition: (direction, expression) =>
       dispatch(historyActions.updateHistoryPosition(direction, expression)),
+    autocompleteUpdate: ({inputValue, cursor, frameActorId, force, client}) => dispatch(
+      autocompleteActions.autocompleteUpdate({
+        inputValue,
+        cursor,
+        frameActorId,
+        force,
+        client,
+      })
+    ),
+    autocompleteBailOut: () => dispatch(autocompleteActions.autocompleteBailOut()),
   };
 }
 
 module.exports = connect(mapStateToProps, mapDispatchToProps)(JSTerm);
--- a/devtools/client/webconsole/constants.js
+++ b/devtools/client/webconsole/constants.js
@@ -3,16 +3,20 @@
 /* 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 actionTypes = {
   APPEND_NOTIFICATION: "APPEND_NOTIFICATION",
   APPEND_TO_HISTORY: "APPEND_TO_HISTORY",
+  AUTOCOMPLETE_CLEAR: "AUTOCOMPLETE_CLEAR",
+  AUTOCOMPLETE_DATA_RECEIVE: "AUTOCOMPLETE_DATA_RECEIVE",
+  AUTOCOMPLETE_PENDING_REQUEST: "AUTOCOMPLETE_PENDING_REQUEST",
+  AUTOCOMPLETE_RETRIEVE_FROM_CACHE: "AUTOCOMPLETE_RETRIEVE_FROM_CACHE",
   BATCH_ACTIONS: "BATCH_ACTIONS",
   CLEAR_HISTORY: "CLEAR_HISTORY",
   DEFAULT_FILTERS_RESET: "DEFAULT_FILTERS_RESET",
   FILTER_BAR_TOGGLE: "FILTER_BAR_TOGGLE",
   FILTER_TEXT_SET: "FILTER_TEXT_SET",
   FILTER_TOGGLE: "FILTER_TOGGLE",
   FILTERS_CLEAR: "FILTERS_CLEAR",
   HISTORY_LOADED: "HISTORY_LOADED",
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/reducers/autocomplete.js
@@ -0,0 +1,104 @@
+/* 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 {
+  AUTOCOMPLETE_CLEAR,
+  AUTOCOMPLETE_DATA_RECEIVE,
+  AUTOCOMPLETE_PENDING_REQUEST,
+  AUTOCOMPLETE_RETRIEVE_FROM_CACHE,
+} = require("devtools/client/webconsole/constants");
+
+function getDefaultState() {
+  return Object.freeze({
+    cache: null,
+    matches: [],
+    matchProp: null,
+    isElementAccess: false,
+    pendingRequestId: null,
+  });
+}
+
+function autocomplete(state = getDefaultState(), action) {
+  switch (action.type) {
+    case AUTOCOMPLETE_CLEAR:
+      return getDefaultState();
+    case AUTOCOMPLETE_RETRIEVE_FROM_CACHE:
+      return autoCompleteRetrieveFromCache(state, action);
+    case AUTOCOMPLETE_PENDING_REQUEST:
+      return {
+        ...state,
+        cache: null,
+        pendingRequestId: action.id,
+      };
+    case AUTOCOMPLETE_DATA_RECEIVE:
+      if (action.id !== state.pendingRequestId) {
+        return state;
+      }
+
+      return {
+        ...state,
+        cache: {
+          input: action.input,
+          frameActorId: action.frameActorId,
+          ...action.data,
+        },
+        pendingRequestId: null,
+        ...action.data,
+      };
+  }
+
+  return state;
+}
+
+/**
+ * Retrieve from cache action reducer.
+ *
+ * @param {Object} state
+ * @param {Object} action
+ * @returns {Object} new state.
+ */
+function autoCompleteRetrieveFromCache(state, action) {
+  const {input} = action;
+  const {cache} = state;
+
+  let filterBy = input;
+  if (cache.isElementAccess) {
+    // if we're performing an element access, we can simply retrieve whatever comes
+    // after the last opening bracket.
+    filterBy = input.substring(input.lastIndexOf("[") + 1);
+  } else {
+    // Find the last non-alphanumeric other than "_", ":", or "$" if it exists.
+    const lastNonAlpha = input.match(/[^a-zA-Z0-9_$:][a-zA-Z0-9_$:]*$/);
+    // If input contains non-alphanumerics, use the part after the last one
+    // to filter the cache.
+    if (lastNonAlpha) {
+      filterBy = input.substring(input.lastIndexOf(lastNonAlpha) + 1);
+    }
+  }
+  const stripWrappingQuotes = s => s.replace(/^['"`](.+(?=['"`]$))['"`]$/g, "$1");
+  const filterByLc = filterBy.toLocaleLowerCase();
+  const looseMatching = !filterBy || filterBy[0].toLocaleLowerCase() === filterBy[0];
+  const needStripQuote = cache.isElementAccess && !/^[`"']/.test(filterBy);
+  const newList = cache.matches.filter(l => {
+    if (needStripQuote) {
+      l = stripWrappingQuotes(l);
+    }
+
+    if (looseMatching) {
+      return l.toLocaleLowerCase().startsWith(filterByLc);
+    }
+
+    return l.startsWith(filterBy);
+  });
+
+  return {
+    ...state,
+    matches: newList,
+    matchProp: filterBy,
+    isElementAccess: cache.isElementAccess,
+  };
+}
+
+exports.autocomplete = autocomplete;
--- a/devtools/client/webconsole/reducers/index.js
+++ b/devtools/client/webconsole/reducers/index.js
@@ -1,25 +1,27 @@
 /* -*- 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 { autocomplete } = require("./autocomplete");
 const { filters } = require("./filters");
 const { messages } = require("./messages");
 const { prefs } = require("./prefs");
 const { ui } = require("./ui");
 const { notifications } = require("./notifications");
 const { history } = require("./history");
 
 const { objectInspector } = require("devtools/client/shared/components/reps/reps.js");
 
 exports.reducers = {
+  autocomplete,
   filters,
   messages,
   prefs,
   ui,
   notifications,
   history,
   objectInspector: objectInspector.reducer.default,
 };
--- a/devtools/client/webconsole/reducers/moz.build
+++ b/devtools/client/webconsole/reducers/moz.build
@@ -1,14 +1,15 @@
 # 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(
+    'autocomplete.js',
     'filters.js',
     'history.js',
     'index.js',
     'messages.js',
     'notifications.js',
     'prefs.js',
     'ui.js',
 )
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/selectors/autocomplete.js
@@ -0,0 +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 getAutocompleteState(state) {
+  return state.autocomplete;
+}
+
+module.exports = {
+  getAutocompleteState,
+};
--- a/devtools/client/webconsole/selectors/moz.build
+++ b/devtools/client/webconsole/selectors/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(
+    'autocomplete.js',
     'filters.js',
     'history.js',
     'messages.js',
     'notifications.js',
     'prefs.js',
     'ui.js',
 )
--- a/devtools/client/webconsole/test/mochitest/browser_jsterm_autocomplete_native_getters.js
+++ b/devtools/client/webconsole/test/mochitest/browser_jsterm_autocomplete_native_getters.js
@@ -15,30 +15,30 @@ add_task(async function() {
   await pushPref("devtools.webconsole.jsterm.codeMirror", false);
   await performTests();
   // And then run it with the CodeMirror-powered one.
   await pushPref("devtools.webconsole.jsterm.codeMirror", true);
   await performTests();
 });
 
 async function performTests() {
-  const { jsterm } = await openNewTabAndConsole(TEST_URI);
+  const { jsterm, ui } = await openNewTabAndConsole(TEST_URI);
 
   const { autocompletePopup: popup } = jsterm;
 
   ok(!popup.isOpen, "popup is not open");
   const onPopupOpen = popup.once("popup-opened");
 
   jsterm.setInputValue("document.body");
   EventUtils.sendString(".");
 
   await onPopupOpen;
 
   ok(popup.isOpen, "popup is open");
-  const cacheMatches = jsterm._autocompleteCache.matches;
+  const cacheMatches = ui.consoleOutput.getStore().getState().autocomplete.cache.matches;
   is(popup.itemCount, cacheMatches.length, "popup.itemCount is correct");
   ok(cacheMatches.includes("addEventListener"),
     "addEventListener is in the list of suggestions");
   ok(cacheMatches.includes("bgColor"), "bgColor is in the list of suggestions");
   ok(cacheMatches.includes("ATTRIBUTE_NODE"),
     "ATTRIBUTE_NODE is in the list of suggestions");
 
   const onPopupClose = popup.once("popup-closed");
--- a/testing/talos/talos/tests/devtools/addon/content/tests/webconsole/autocomplete.js
+++ b/testing/talos/talos/tests/devtools/addon/content/tests/webconsole/autocomplete.js
@@ -64,16 +64,13 @@ async function triggerAutocompletePopup(
 function hideAutocompletePopup(jsterm) {
   let onPopUpClosed = jsterm.autocompletePopup.once("popup-closed");
   setJsTermValueForCompletion(jsterm, "");
   return onPopUpClosed;
 }
 
 function setJsTermValueForCompletion(jsterm, value) {
   // setInputValue does not trigger the autocompletion;
-  // we need to call `updateAutocompletion` in order to display the popup. And since
-  // setInputValue sets lastInputValue and updateAutocompletion checks it to trigger
-  // the autocompletion request, we reset it.
+  // we need to call `fetchAutocompletionProperties` in order to display the popup.
   jsterm.setInputValue(value);
-  jsterm.lastInputValue = null;
-  jsterm.updateAutocompletion();
+  jsterm.fetchAutocompletionProperties();
 }
 
--- a/testing/talos/talos/tests/devtools/addon/content/tests/webconsole/typing.js
+++ b/testing/talos/talos/tests/devtools/addon/content/tests/webconsole/typing.js
@@ -38,20 +38,18 @@ module.exports = async function() {
   jsterm.focus();
 
   const test = runTest(TEST_NAME);
 
   // Simulate typing in the input.
   for (const char of Array.from(input)) {
     const onPopupOpened = jsterm.autocompletePopup.once("popup-opened");
     jsterm.insertStringAtCursor(char);
-    // We need to remove the lastInputValue set by setInputValue(called by
-    // insertStringAtCursor), and trigger autocompletion update to show the popup.
-    jsterm.lastInputValue = null;
-    jsterm.updateAutocompletion();
+    // We need to trigger autocompletion update to show the popup.
+    jsterm.fetchAutocompletionProperties();
     await onPopupOpened;
   }
 
   test.done();
 
   const onPopupClosed = jsterm.autocompletePopup.once("popup-closed");
   jsterm.clearCompletion();
   await onPopupClosed;