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:
		
							parent
							
								
									cef76e6bba
								
							
						
					
					
						commit
						de220e7262
					
				
					 6 changed files with 58 additions and 25 deletions
				
			
		| 
						 | 
				
			
			@ -148,7 +148,8 @@
 | 
			
		|||
      "NodeList",
 | 
			
		||||
      "DOMParser",
 | 
			
		||||
      "CSS",
 | 
			
		||||
      "customElements"
 | 
			
		||||
      "customElements",
 | 
			
		||||
      "AbortController"
 | 
			
		||||
    ],
 | 
			
		||||
    "ignore": [
 | 
			
		||||
      "dist",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										15
									
								
								src/routes/_utils/PromiseThrottler.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/routes/_utils/PromiseThrottler.js
									
										
									
									
									
										Normal 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
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		
		Reference in a new issue