fix(tradein/ui): чистить TanStack Query cache при смене identity (#2567)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m1s

Логин, логаут и 401-редирект инвалидировали только ME_QUERY_KEY — данные
предыдущего пользователя (team/employees, история оценок, квоты) оставались
в кэше и могли отрендериться следующему в той же SPA-сессии до рефетча.
queryClient.clear() во всех трёх точках смены identity.

clear() вместо точечного removeQueries: allowlist ключей пришлось бы
синхронизировать с каждым новым запросом приложения, один промах — снова
утечка. In-flight запросы безопасны: удалённый Query получает новый
инстанс при следующей подписке, поздний ответ старого пользователя пишет
в осиротевший объект. Легаси basic-auth logout (hard reload) не тронут —
там весь JS-heap и так уничтожается. Persist-слоя у QueryClient нет.

Refs #2567
This commit is contained in:
bot-backend 2026-08-05 15:03:40 +05:00
parent 5b3b21d84c
commit 482b0be1fa
3 changed files with 24 additions and 5 deletions

View file

@ -20,7 +20,6 @@ import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiFetch, HTTPError } from "@/lib/api";
import { ME_QUERY_KEY } from "@/lib/useMe";
import { AnonSupportWidget } from "@/components/auth/AnonSupportWidget";
interface LoginInput {
@ -140,7 +139,11 @@ export default function LoginPage() {
const loginMutation = useMutation({
mutationFn: loginRequest,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY });
// #2567: вход под другим юзером без предварительного logout (тот же
// девайс/вкладка) — снимаем ВЕСЬ кэш, не только /me, иначе
// team/employees, история оценок и т.п. предыдущего юзера переживают
// login и могут отрисоваться до первого рефетча.
queryClient.clear();
router.push(sanitizeNext(readNextParam()));
},
});

View file

@ -42,6 +42,7 @@
* задвоил бы префикс в `/trade-in/trade-in/history`.
*/
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
@ -62,6 +63,7 @@ export default function GuardedRoute({
children: React.ReactNode;
}) {
const router = useRouter();
const queryClient = useQueryClient();
// Абсолютный путь сайта: BASE_PATH + rawPath. Аккуратно с двойным слэшем
// на `/`: `BASE_PATH = "/trade-in"` + `"/"` → `/trade-in/` (ок).
const absolutePath = BASE_PATH
@ -85,6 +87,13 @@ export default function GuardedRoute({
useEffect(() => {
if (!shouldRedirectToLogin) return;
// #2567: сессия протухла (401) — снимаем кэш ДО редиректа на /login. Тот
// же самый девайс/вкладка может через секунду залогиниться другим
// юзером; без clear() здесь team/employees, история оценок и т.п.
// предыдущего юзера дожидались бы login-точки (её `clear()` тоже
// сработает, но лучше не полагаться на единственную точку сброса
// 401 — самостоятельный путь смены identity, не только logout/login).
queryClient.clear();
// PR #2562 review finding 3: deep-links carry их state в query (`/v2?id=
// <uuid>` — см. next.config.ts redirect comment про restore-by-id). Без
// `window.location.search` юзер, чья сессия истекла mid-session на такой
@ -93,7 +102,7 @@ export default function GuardedRoute({
// поэтому `window` тут безопасен без typeof-guard.
const next = `${rawPath}${window.location.search}`;
router.push(`/login?next=${encodeURIComponent(next)}`);
}, [shouldRedirectToLogin, rawPath, router]);
}, [shouldRedirectToLogin, rawPath, router, queryClient]);
// #801: preview-страница самодостаточна (свой QueryClient с фейковым me),
// RBAC к ней не применяем. Только под флагом — в проде по умолчанию выключено.

View file

@ -14,13 +14,20 @@
* локальный logout (кэш + редирект) выполняется в `onSettled`, а не только
* `onSuccess`: сетевой сбой / уже-протухшая сессия не должны запирать юзера
* на странице без возможности разлогиниться.
*
* #2567: `clear()`, а не точечный invalidate ME_QUERY_KEY иначе кэш
* остального (team/employees, история оценок, квоты) переживает logout и
* может отрисоваться следующему юзеру той же вкладки до первого рефетча.
* `clear()` безопасен для in-flight запросов: TanStack Query создаёт для
* снятого из кэша ключа новый `Query`-инстанс при следующей подписке, так
* что поздний resolve старого fetch пишет в осиротевший объект, а не в
* видимый кэш.
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { ME_QUERY_KEY } from "@/lib/useMe";
async function logoutRequest(): Promise<void> {
await apiFetch<{ ok: boolean }>("/api/v1/auth/logout", { method: "POST" });
@ -33,7 +40,7 @@ export function useLogout() {
return useMutation({
mutationFn: logoutRequest,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY });
queryClient.clear();
router.push("/login");
},
});