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",
"DOMParser",
"CSS",
"customElements"
"customElements",
"AbortController"
],
"ignore": [
"dist",

View file

@ -5,8 +5,10 @@ import { SEARCH_RESULTS_LIMIT } from '../_static/autosuggest'
import { concat } from '../_utils/arrays'
import uniqBy from 'lodash-es/uniqBy'
import { scheduleIdleTask } from '../_utils/scheduleIdleTask'
import { PromiseThrottler } from '../_utils/PromiseThrottler'
const DATABASE_SEARCH_RESULTS_LIMIT = 30
const promiseThrottler = new PromiseThrottler(200) // Mastodon FE also uses 200ms
function byUsername (a, b) {
let usernameA = a.acct.toLowerCase()
@ -24,6 +26,14 @@ export function doAccountSearch (searchText) {
let localResults
let remoteResults
let { currentInstance, accessToken } = store.get()
let controller = typeof AbortController === 'function' && new AbortController()
function abortFetch () {
if (controller) {
controller.abort()
controller = null
}
}
async function searchAccountsLocally (searchText) {
localResults = await database.searchAccountsByUsername(
@ -31,7 +41,14 @@ export function doAccountSearch (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 () {
@ -81,6 +98,7 @@ export function doAccountSearch (searchText) {
return {
cancel: () => {
canceled = true
abortFetch()
}
}
}

View file

@ -1,11 +1,14 @@
import { get, paramsString, DEFAULT_TIMEOUT } from '../_utils/ajax'
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({
q: query,
resolve,
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
store.observe('autosuggestSearchText', async autosuggestSearchText => {
let { composeFocused } = store.get()
if (!composeFocused || !autosuggestSearchText) {
return
// cancel any inflight XHRs or other operations
if (lastSearch) {
lastSearch.cancel()
lastSearch = null
}
/* 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.
We can also cancel any inflight XHRs here.
*/
// 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.
let { composeFocused } = store.get()
let autosuggestSelecting = store.getForCurrentAutosuggest('autosuggestSelecting')
if (autosuggestSelecting) {
if (lastSearch) {
lastSearch.cancel()
lastSearch = null
}
if (!composeFocused || !autosuggestSearchText || autosuggestSelecting) {
return
}
let autosuggestType = autosuggestSearchText.startsWith('@') ? 'account' : 'emoji'
if (lastSearch) {
lastSearch.cancel()
}
if (autosuggestType === 'emoji') {
lastSearch = doEmojiSearch(autosuggestSearchText)
} 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) {
return {
function makeFetchOptions (method, headers, options) {
let res = {
method,
headers: Object.assign(headers || {}, {
'Accept': 'application/json'
})
}
if (options && options.signal) {
res.signal = options.signal
}
return res
}
async function throwErrorIfInvalidResponse (response) {
@ -40,7 +44,7 @@ async function _fetch (url, fetchOptions, options) {
}
async function _putOrPostOrPatch (method, url, body, headers, options) {
let fetchOptions = makeFetchOptions(method, headers)
let fetchOptions = makeFetchOptions(method, headers, options)
if (body) {
if (body instanceof FormData) {
fetchOptions.body = body
@ -65,11 +69,11 @@ export async function patch (url, body, 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) {
return _fetch(url, makeFetchOptions('DELETE', headers), options)
return _fetch(url, makeFetchOptions('DELETE', headers, options), options)
}
export function paramsString (paramsObject) {