Ревью: корректность фронтенда

Находки прохода /code-review high по frontend/src:

- CreateMatchPage: барабан рандома, таймеры и строки адресовались по индексу,
  а удаление строки индексы сдвигает — фракция могла записаться соседу или
  потеряться. Строки получили стабильный id.
- AdminAccountsPage: переключение «активен» глотало ошибку и всё равно
  показывало «Сохранено».
- useServerEvents/useFinishMatch: завершение чужой партии не инвалидировало
  историю игр и публичные профили — открытый профиль показывал состав до
  завершения.
- guards/useMe: обрыв связи не отличался от «нет сессии», и мигание сети
  выкидывало авторизованного пользователя на /login. Транспортные ошибки
  повторяем, гварды показывают сообщение вместо редиректа.
- format: fallback на нераспознанную дату не работал (new Date не бросает
  исключение), и в интерфейс попадало «NaN.NaN NaN:NaN».
- PlaceEditor: без onPointerCancel прерванный перенос оставлял блок с классом
  dragging и сдвигом, которые React не снимает — они выставлены в обход него.
- AdminFactionsPage: refetch после сохранения одной фракции затирал
  несохранённый ввод в остальных.
- MatchDetailPage: nav(-1) после удаления уводил из приложения при открытии
  партии по прямой ссылке; id из URL мог быть NaN и уходил в запрос.

#8

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186Fk74jkkszahEHSjBzTjD
This commit is contained in:
2026-09-09 15:30:56 +03:00
co-authored by Claude Opus 5
parent 62d75ea176
commit c4b72cca39
10 changed files with 105 additions and 57 deletions
+3
View File
@@ -12,6 +12,9 @@ export function useMe() {
if (r.response.status === 401) return null;
return unwrap(r);
},
// «Нет сессии» — это только 401 (выше, null). Обрыв связи не ответ сервера:
// без повторов гварды принимали бы его за разлогин и уводили на /login.
retry: (count, err) => !(err instanceof ApiError) && count < 2,
});
}
+6 -1
View File
@@ -12,7 +12,9 @@ function readCsrfToken(): string | null {
export function useMatch(matchId: number | null) {
return useQuery({
queryKey: matchId ? qk.match(matchId) : ["match", "none"],
// Ключ и enabled должны сходиться: иначе запрос без валидного id кэшировал бы
// свою ошибку под общим ключом-заглушкой.
queryKey: matchId != null ? qk.match(matchId) : ["match", "none"],
enabled: matchId != null,
queryFn: async () =>
unwrap(
@@ -63,6 +65,9 @@ export function useFinishMatch() {
qc.invalidateQueries({ queryKey: qk.leaderboard });
qc.invalidateQueries({ queryKey: qk.home });
qc.invalidateQueries({ queryKey: qk.myStats });
// Завершённая партия попадает в историю игр и в публичные профили участников.
qc.invalidateQueries({ queryKey: ["userMatches"] });
qc.invalidateQueries({ queryKey: ["publicProfile"] });
},
});
}
+5
View File
@@ -40,6 +40,11 @@ export function useServerEvents(enabled: boolean) {
}
qc.invalidateQueries({ queryKey: qk.home });
qc.invalidateQueries({ queryKey: qk.leaderboard });
// История игр и публичный профиль тоже меняются от чужой партии: без этих
// двух ключей открытый профиль показывал бы состав до завершения.
qc.invalidateQueries({ queryKey: ["userMatches"] });
qc.invalidateQueries({ queryKey: ["publicProfile"] });
qc.invalidateQueries({ queryKey: qk.myStats });
} else if (ev.type === "group") {
if (ev.group_id != null) {
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });