Есть категория багов, которую не ловит ни одна сборка. Типы сходятся. Линтер доволен. Тесты зелёные. Страница рисуется. И всё равно сломано — потому что сломался не код, а связь между двумя кусками кода, которые никакой инструмент не просили сравнить.
Три таких вышли из одного проекта за одну неделю. Ни один не экзотический. Все три вы встретите снова.
1. Второй список
Сайт на трёх языках. Английский в корне, румынский под /ro, русский под /ru.
Функция решает, существует ли адрес во всех трёх, а сборщик ссылок использует её,
чтобы удержать посетителя в его языке:
export const TRANSLATED_PATHS = [
"/", "/work", "/services", "/faq", "/contact",
"/work/erp", "/work/vision", "/work/commerce", "/work/trading",
] as const;
export function useLangPath() {
const lang = useLang();
return (path: string) =>
hasTranslation(path) ? withLang(lang, path) : path;
}
Прочитайте фолбэк в последней строке. Адрес, который функция не распознала, возвращается без изменений — что верно для двух юридических страниц, намеренно только на английском, и молча неверно для всего остального.
Этот список писался руками и отставал трижды: когда добавились три кейса, когда добавились семь страниц услуг и когда добавилась страница «О нас». Никто не пожаловался. Маршруты существовали, были пререндерены, лежали в sitemap, отдавали 200. Посетитель, читавший по-румынски, нажимал на услугу и просто оказывался на английском.
Девяносто восемь ссылок по всему сайту. Посчитаны потом — обходом пререндеренного вывода в поисках адресов без языкового префикса на страницах, у которых он был:
for (const file of prerenderedPages) {
if (!/^(ro|ru)_/.test(basename(file))) continue;
for (const href of hrefsIn(file)) {
if (!href.startsWith('/ro/') && !href.startsWith('/ru/')) suspicious.push(href);
}
}
Эта же функция решает, получит ли страница альтернативы hreflang и куда
отправит переключатель языка. То есть одна отсутствующая запись породила три
отдельных дефекта: посетителей выбрасывало из их языка, страницы публиковались
без альтернатив, а переключатель ронял вас на главную вместо той страницы,
которую вы читали.
Решение — не более длинный список. Решение — чтобы второго не было:
export const TRANSLATED_PATHS = [
"/", "/work", "/services", "/faq", "/contact", "/about",
...CASE_SLUGS.map((s) => `/work/${s}`),
...SERVICE_SLUGS.map((s) => `/services/${s}`),
] as const;
Эти массивы слагов уже питают роутер и шаг пререндера. Выводя список из них, маршрут физически не может существовать в одном месте и отсутствовать в другом.
Общая форма: каждый раз, когда вы пишете список, который обязан совпадать с другим списком, вы создаёте баг с замедлителем. Вопрос не в том, разойдутся ли они, а когда.
2. Кнопка, на которую нельзя нажать
Секция hero рисует анимированный материал за текстом, на canvas. Canvas нуждается в указателе, поэтому слой над ним его отдаёт:
.hero .herostage { pointer-events: none; }
.hero .herostage a,
.hero .figs { pointer-events: auto; }
Прочитайте второе правило. Оно возвращает события ссылкам. Главный призыв к
действию на сайте — тот, что открывает форму проекта, — это <button>, потому
что он открывает что-то на странице, а не ведёт по адресу.
Значит, он унаследовал pointer-events: none и нажать на него было нельзя. Ни
мышью, ни пальцем. Он корректно рисовался, получал видимую рамку фокуса с
клавиатуры, у него был рабочий onClick, и по клику не происходило ничего.
Соседняя ссылка «See the work» работала — потому что это якорь.
Это пережило полный аудит мобильного дизайна — замеры зон нажатия, контраста, порядка заголовков, — потому что каждая из тех проверок читает геометрию и вычисленный стиль, а с геометрией всё было в порядке. Нашёл его скрипт, который попытался нажать:
- attempting tap action
- <section class="hero">…</section> intercepts pointer events
- retrying tap action
TimeoutError: tap: Timeout 30000ms exceeded.
Вот и весь метод обнаружения: не «есть ли кнопка», а «делает ли что-нибудь нажатие на неё».
.hero .herostage a,
.hero .herostage button,
.hero .figs { pointer-events: auto; }
Общая форма: pointer-events: none на контейнере — это обещание, что вы
будете вечно помнить каждый вид интерактивного потомка. Селекторы по элементу
молча кодируют предположение о том, что содержит ваша разметка. Когда разметка
получает <button>, <summary>, <input> или <label>, обещание ломается, и
никто вам об этом не сообщает.
3. Правило, проигравшее написанному раньше
Внизу страниц кейсов стоит фиксированная полоса, показывающая фазу 3D-сцены. На телефоне сцена вообще не загружается — она рисовалась за текстом с очень низким контрастом, то есть тратила батарею на то, чего никто не видит, — и полосе больше нечего описывать:
@media (max-width: 900px) {
#gl, #glfallback, .phasebar { display: none; }
}
Полоса осталась видимой. Поверх текста.
На девяносто строк выше, в том же файле стилей:
.phasebar { position: fixed; /* … */ }
.phasebar.on { display: block; }
У .phasebar.on специфичность (0,2,0). У .phasebar — (0,1,0). Порядок в файле
решает только между правилами равной специфичности, а медиазапрос не добавляет
к специфичности ровно ничего. Правило ниже проиграло правилу выше — в точности
так, как описывает каскад.
Решение — сравнять специфичность с той, которую вы перебиваете:
@media (max-width: 900px) {
#gl, #glfallback, .phasebar, .phasebar.on { display: none; }
}
Не !important. Он выигрывает спор и проигрывает файл: каждому следующему
переопределению понадобится свой !important, и через год таблица стилей — стопка
из них, в которой невозможно понять, что применяется.
Общая форма: легко поверить, что медиазапрос сильнее обычного правила. Он не сильнее. Это условие, а не вес. Любое правило, которое вы собираетесь перебить изнутри медиазапроса, придётся побеждать на его же поле.
Что у них общего
Ни один из них не ошибка в смысле опечатки. Каждый — верное решение, переставшее быть верным, когда вокруг изменилось что-то ещё.
Список, написанный руками, верен, пока не вырастет то, что он отражает. Селектор по элементу верен, пока разметка не получит элемент, который он не называет. Специфичность верна, пока вы не попробуете перебить её оттуда, откуда казалось сильнее.
Ловится это скучно конкретными вещами:
- Выводите списки, а не пишите их дважды. Если два списка обязаны совпадать, один из них должен вычисляться из другого.
- Тестируйте взаимодействие, а не отрисовку. «Кнопка есть и она 44 пикселя» и «кнопка что-то делает при нажатии» — разные утверждения, и только второе поймало бы баг 2.
- Читайте вычисленный стиль, а не таблицу стилей. В баге 3 таблица говорила
display: none, а элемент —display: block. Один вызовgetComputedStyleзаканчивает спор за секунду.
Тема под всеми тремя: сборка проверяет каждый файл. Она никогда не проверяет, что два файла всё ещё согласуются друг с другом. В этом зазоре и живёт вся категория.