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

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Nicolas Chevobbe 2018-11-12 16:07:37 +00:00
parent 918425999d
commit 7b12453efb
13 changed files with 342 additions and 147 deletions

View File

@ -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,
};

View File

@ -7,6 +7,7 @@
"use strict";
const actionModules = [
require("./autocomplete"),
require("./filters"),
require("./messages"),
require("./ui"),

View File

@ -4,6 +4,7 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
DevToolsModules(
'autocomplete.js',
'filters.js',
'history.js',
'index.js',

View File

@ -39,7 +39,9 @@ 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 {
@ -77,6 +79,10 @@ class JSTerm extends Component {
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,
};
}
@ -94,40 +100,16 @@ class JSTerm extends Component {
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;
@ -356,7 +338,7 @@ class JSTerm extends Component {
"Ctrl-Space": () => {
if (!this.autocompletePopup.isOpen) {
this.updateAutocompletion(true);
this.fetchAutocompletionProperties(true);
return null;
}
@ -403,13 +385,32 @@ class JSTerm extends Component {
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
@ -773,7 +774,7 @@ class JSTerm extends Component {
const value = this.getInputValue();
if (this.lastInputValue !== value) {
this.resizeInput();
this.updateAutocompletion();
this.fetchAutocompletionProperties();
this.lastInputValue = value;
}
}
@ -860,7 +861,7 @@ class JSTerm extends Component {
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();
}
@ -1104,137 +1105,64 @@ class JSTerm extends Component {
}
/**
* 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,
@ -1673,15 +1601,27 @@ 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()),
};
}

View File

@ -8,6 +8,10 @@
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",

View File

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

View File

@ -5,6 +5,7 @@
* 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");
@ -15,6 +16,7 @@ const { history } = require("./history");
const { objectInspector } = require("devtools/client/shared/components/reps/reps.js");
exports.reducers = {
autocomplete,
filters,
messages,
prefs,

View File

@ -4,6 +4,7 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
DevToolsModules(
'autocomplete.js',
'filters.js',
'history.js',
'index.js',

View File

@ -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,
};

View File

@ -4,6 +4,7 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
DevToolsModules(
'autocomplete.js',
'filters.js',
'history.js',
'messages.js',

View File

@ -20,7 +20,7 @@ add_task(async function() {
});
async function performTests() {
const { jsterm } = await openNewTabAndConsole(TEST_URI);
const { jsterm, ui } = await openNewTabAndConsole(TEST_URI);
const { autocompletePopup: popup } = jsterm;
@ -33,7 +33,7 @@ async function performTests() {
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");

View File

@ -69,11 +69,8 @@ function hideAutocompletePopup(jsterm) {
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();
}

View File

@ -43,10 +43,8 @@ module.exports = async function() {
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;
}