autocomplete-race-conditions.md — vim

Автокомплит адресов: маленький компонент, огромные грабли

«Иногда подсказки не совпадают с тем, что я ввёл» - баг, который невозможно воспроизвести по описанию. Разбираю его и ещё три из нашего сервиса адресов.


Когда мы делали свой сервис адресов поверх ГАР (по сути аналог Dadata, только свой), самым проблемным компонентом оказалось поле ввода с подсказками. Выглядит как инпут, а внутри три классические засады.

Запрос на каждую букву

Человек печатает «Иркутск, улица Ленина» - это 21 символ и 21 запрос. Бэкенд не в восторге, пользователь тоже.

const search = useDebounceFn(async (q: string) => {
  suggestions.value = await addressApi.suggest(q);
}, 300);

watch(query, (q) => {
  if (q.trim().length < 3) { suggestions.value = []; return; }
  search(q);
});

Почему 300 мс, а не 500 или 150: пауза между нажатиями при быстром наборе - примерно 150-200 мс, так что 300 отсекает набор, но остаётся заметно ниже секунды. Секунда - это классический порог у NN/g, после которого человек перестаёт чувствовать, что система отвечает сразу.

Ещё две мелочи, которые вечно забывают: не искать по одному-двум символам (сервер вернёт полбазы) и не искать по пустой строке, когда поле очистили.

Ответы приходят не в том порядке

Вот тот самый баг, который приходит из поддержки в формулировке «иногда подсказки не соответствуют введённому тексту» и не воспроизводится у разработчика никогда.

Пользователь напечатал «ирк», потом «иркутск». Запросы ушли по порядку, а ответ на «ирк» пришёл позже - сеть порядок не гарантирует. В поле висит список от старого запроса.

Лечится в два слоя. Сначала отменяем предыдущий запрос:

let controller: AbortController | null = null;

async function suggest(q: string) {
  controller?.abort();
  controller = new AbortController();

  try {
    const res = await fetch(`/api/suggest?q=${encodeURIComponent(q)}`, {
      signal: controller.signal,
    });
    suggestions.value = await res.json();
  } catch (e) {
    if ((e as Error).name === 'AbortError') return; // это не ошибка, это мы сами
    throw e;
  }
}

Axios понимает тот же signal, так что приём переносится один в один.

А потом всё равно проверяем актуальность ответа - отмена не всегда успевает:

let lastRequestId = 0;

async function suggest(q: string) {
  const id = ++lastRequestId;
  const data = await addressApi.suggest(q);
  if (id !== lastRequestId) return; // ответ устарел, выбрасываем
  suggestions.value = data;
}

На TanStack Query оба механизма приезжают бесплатно: ключ ['suggest', query] разводит ответы, устаревшие в актуальный кэш не попадают.

Мышка есть, клавиатуры нет

Автокомплит, в котором нельзя выбрать вариант с клавиатуры, - это половина автокомплита. Минимум, который я требую:

function onKeydown(e: KeyboardEvent) {
  if (e.key === 'ArrowDown') { active.value = Math.min(active.value + 1, suggestions.value.length - 1); e.preventDefault(); }
  if (e.key === 'ArrowUp')   { active.value = Math.max(active.value - 1, 0); e.preventDefault(); }
  if (e.key === 'Enter' && suggestions.value[active.value]) select(suggestions.value[active.value]);
  if (e.key === 'Escape')    close();
}

Плюс подсветка активного элемента с прокруткой в зону видимости, плюс role="combobox" с aria-expanded и aria-activedescendant - без них скринридер не понимает, что вообще происходит. И обработка mousedown вместо click, иначе клик по подсказке будет теряться из-за blur. На этом мы потеряли часа три, пока не поняли, почему выбор мышкой иногда «не срабатывает».

Сервер тоже надо пожалеть

Три вещи, которые сняли нам основную нагрузку:

Кэш в памяти на сессию. Люди постоянно стирают и печатают заново, Map на сотню записей отбивает добрую часть повторов.

Cache-Control: public, max-age=3600 на ответах. Адреса меняются раз в квартал, а не раз в секунду - и это ноль строк кода на фронте.

Десять подсказок вместо пятидесяти. Дальше пятой строки никто не смотрит, а трафик и рендер падают в разы.

Проверить себя просто: открой DevTools, поставь throttling «Slow 4G» и быстро напечатай запрос в свой автокомплит. Больше трёх-четырёх запросов или подсказки, не совпадающие с текстом, - у тебя ровно те баги, о которых выше.

Ссылки