diff --git a/package.json b/package.json index 9417d21f..579e5988 100644 --- a/package.json +++ b/package.json @@ -148,7 +148,8 @@ "NodeList", "DOMParser", "CSS", - "customElements" + "customElements", + "AbortController" ], "ignore": [ "dist", diff --git a/src/routes/_actions/autosuggestAccountSearch.js b/src/routes/_actions/autosuggestAccountSearch.js index 9acc5f5e..b0ed65df 100644 --- a/src/routes/_actions/autosuggestAccountSearch.js +++ b/src/routes/_actions/autosuggestAccountSearch.js @@ -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() } } } diff --git a/src/routes/_api/search.js b/src/routes/_api/search.js index 11298e07..e0aa0a60 100644 --- a/src/routes/_api/search.js +++ b/src/routes/_api/search.js @@ -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 + }) } diff --git a/src/routes/_store/observers/autosuggestObservers.js b/src/routes/_store/observers/autosuggestObservers.js index c0277ed9..e1643a2a 100644 --- a/src/routes/_store/observers/autosuggestObservers.js +++ b/src/routes/_store/observers/autosuggestObservers.js @@ -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 { diff --git a/src/routes/_utils/PromiseThrottler.js b/src/routes/_utils/PromiseThrottler.js new file mode 100644 index 00000000..3d776857 --- /dev/null +++ b/src/routes/_utils/PromiseThrottler.js @@ -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 + } +} diff --git a/src/routes/_utils/ajax.js b/src/routes/_utils/ajax.js index b7908190..bca8dd2b 100644 --- a/src/routes/_utils/ajax.js +++ b/src/routes/_utils/ajax.js @@ -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) {