fix: throttle XHRs from autosuggest (#1190)

* fix: throttle XHRs from autosuggest

* throttle and abort properly

* add comment

* fix xhr bug
This commit is contained in:
Nolan Lawson 2019-05-06 20:29:43 -07:00 committed by GitHub
parent cef76e6bba
commit de220e7262
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 58 additions and 25 deletions

View File

@ -148,7 +148,8 @@
"NodeList", "NodeList",
"DOMParser", "DOMParser",
"CSS", "CSS",
"customElements" "customElements",
"AbortController"
], ],
"ignore": [ "ignore": [
"dist", "dist",

View File

@ -5,8 +5,10 @@ import { SEARCH_RESULTS_LIMIT } from '../_static/autosuggest'
import { concat } from '../_utils/arrays' import { concat } from '../_utils/arrays'
import uniqBy from 'lodash-es/uniqBy' import uniqBy from 'lodash-es/uniqBy'
import { scheduleIdleTask } from '../_utils/scheduleIdleTask' import { scheduleIdleTask } from '../_utils/scheduleIdleTask'
import { PromiseThrottler } from '../_utils/PromiseThrottler'
const DATABASE_SEARCH_RESULTS_LIMIT = 30 const DATABASE_SEARCH_RESULTS_LIMIT = 30
const promiseThrottler = new PromiseThrottler(200) // Mastodon FE also uses 200ms
function byUsername (a, b) { function byUsername (a, b) {
let usernameA = a.acct.toLowerCase() let usernameA = a.acct.toLowerCase()
@ -24,6 +26,14 @@ export function doAccountSearch (searchText) {
let localResults let localResults
let remoteResults let remoteResults
let { currentInstance, accessToken } = store.get() let { currentInstance, accessToken } = store.get()
let controller = typeof AbortController === 'function' && new AbortController()
function abortFetch () {
if (controller) {
controller.abort()
controller = null
}
}
async function searchAccountsLocally (searchText) { async function searchAccountsLocally (searchText) {
localResults = await database.searchAccountsByUsername( localResults = await database.searchAccountsByUsername(
@ -31,7 +41,14 @@ export function doAccountSearch (searchText) {
} }
async function searchAccountsRemotely (searchText) { async function searchAccountsRemotely (searchText) {
remoteResults = (await search(currentInstance, accessToken, searchText, false, SEARCH_RESULTS_LIMIT)).accounts // Throttle our XHRs to be a good citizen and not spam the server with one XHR per keystroke
await promiseThrottler.next()
if (canceled) {
return
}
remoteResults = (await search(
currentInstance, accessToken, searchText, false, SEARCH_RESULTS_LIMIT, controller && controller.signal
)).accounts
} }
function mergeAndTruncateResults () { function mergeAndTruncateResults () {
@ -81,6 +98,7 @@ export function doAccountSearch (searchText) {
return { return {
cancel: () => { cancel: () => {
canceled = true canceled = true
abortFetch()
} }
} }
} }

View File

@ -1,11 +1,14 @@
import { get, paramsString, DEFAULT_TIMEOUT } from '../_utils/ajax' import { get, paramsString, DEFAULT_TIMEOUT } from '../_utils/ajax'
import { auth, basename } from './utils' import { auth, basename } from './utils'
export function search (instanceName, accessToken, query, resolve = true, limit = 40) { export function search (instanceName, accessToken, query, resolve = true, limit = 40, signal = null) {
let url = `${basename(instanceName)}/api/v1/search?` + paramsString({ let url = `${basename(instanceName)}/api/v1/search?` + paramsString({
q: query, q: query,
resolve, resolve,
limit limit
}) })
return get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT }) return get(url, auth(accessToken), {
timeout: DEFAULT_TIMEOUT,
signal
})
} }

View File

@ -6,29 +6,21 @@ export function autosuggestObservers () {
let lastSearch let lastSearch
store.observe('autosuggestSearchText', async autosuggestSearchText => { store.observe('autosuggestSearchText', async autosuggestSearchText => {
let { composeFocused } = store.get() // cancel any inflight XHRs or other operations
if (!composeFocused || !autosuggestSearchText) { if (lastSearch) {
return lastSearch.cancel()
lastSearch = null
} }
/* autosuggestSelecting indicates that the user has pressed Enter or clicked on an item // autosuggestSelecting indicates that the user has pressed Enter or clicked on an item
and the results are being processed. Returning early avoids a flash of searched content. // and the results are being processed. Returning early avoids a flash of searched content.
We can also cancel any inflight XHRs here. let { composeFocused } = store.get()
*/
let autosuggestSelecting = store.getForCurrentAutosuggest('autosuggestSelecting') let autosuggestSelecting = store.getForCurrentAutosuggest('autosuggestSelecting')
if (autosuggestSelecting) { if (!composeFocused || !autosuggestSearchText || autosuggestSelecting) {
if (lastSearch) {
lastSearch.cancel()
lastSearch = null
}
return return
} }
let autosuggestType = autosuggestSearchText.startsWith('@') ? 'account' : 'emoji' let autosuggestType = autosuggestSearchText.startsWith('@') ? 'account' : 'emoji'
if (lastSearch) {
lastSearch.cancel()
}
if (autosuggestType === 'emoji') { if (autosuggestType === 'emoji') {
lastSearch = doEmojiSearch(autosuggestSearchText) lastSearch = doEmojiSearch(autosuggestSearchText)
} else { } else {

View File

@ -0,0 +1,15 @@
// Utility for throttling in the Lodash style (assuming leading: true and trailing: true) but
// creates a promise.
export class PromiseThrottler {
constructor (timeout) {
this._timeout = timeout
this._promise = Promise.resolve()
}
next () {
let res = this._promise
// update afterwards, so we get a "leading" XHR
this._promise = this._promise.then(() => new Promise(resolve => setTimeout(resolve, this._timeout)))
return res
}
}

View File

@ -9,13 +9,17 @@ function fetchWithTimeout (url, fetchOptions, timeout) {
}) })
} }
function makeFetchOptions (method, headers) { function makeFetchOptions (method, headers, options) {
return { let res = {
method, method,
headers: Object.assign(headers || {}, { headers: Object.assign(headers || {}, {
'Accept': 'application/json' 'Accept': 'application/json'
}) })
} }
if (options && options.signal) {
res.signal = options.signal
}
return res
} }
async function throwErrorIfInvalidResponse (response) { async function throwErrorIfInvalidResponse (response) {
@ -40,7 +44,7 @@ async function _fetch (url, fetchOptions, options) {
} }
async function _putOrPostOrPatch (method, url, body, headers, options) { async function _putOrPostOrPatch (method, url, body, headers, options) {
let fetchOptions = makeFetchOptions(method, headers) let fetchOptions = makeFetchOptions(method, headers, options)
if (body) { if (body) {
if (body instanceof FormData) { if (body instanceof FormData) {
fetchOptions.body = body fetchOptions.body = body
@ -65,11 +69,11 @@ export async function patch (url, body, headers, options) {
} }
export async function get (url, headers, options) { export async function get (url, headers, options) {
return _fetch(url, makeFetchOptions('GET', headers), options) return _fetch(url, makeFetchOptions('GET', headers, options), options)
} }
export async function del (url, headers, options) { export async function del (url, headers, options) {
return _fetch(url, makeFetchOptions('DELETE', headers), options) return _fetch(url, makeFetchOptions('DELETE', headers, options), options)
} }
export function paramsString (paramsObject) { export function paramsString (paramsObject) {