From 10f4f6adacdb6b18f12a29688f0261f4b09aad6a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 17:43:57 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/v2):=203-state=20pendingRestoreId?= =?UTF-8?q?=20sentinel=20=E2=80=94=20null=20=D0=B8=20undefined=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=BD=D1=8B=D0=B5=20=D0=B2=D0=B5=D1=89=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Живая проверка в браузере (клик по строке "Предыдущие оценки" на проде) подтвердила: router.replace() — асинхронный client-nav (RSC round-trip), window.location.search не обновляется синхронно, а readUrlId() читает window.location.search напрямую (useSearchParams намеренно не используется — ломает next build через Suspense). Единственный надёжный источник urlId в синхронном ре-рендере — override-state, выставленный в том же тике, что и клик. Предыдущая версия (`pendingRestoreId ?? readUrlId()`) использовала null для двух разных смыслов сразу: "нет override" и "принудительно пусто". handleNew() выставлял null именно во втором смысле (сброс после 404), но ?? тут же трактовал null как "нет override" и падал обратно на readUrlId() — который на тот момент ещё возвращал старый плохой id (router.replace("/v2") ещё не успел домотать). Итог: "НОВАЯ ОЦЕНКА" после битой ссылки могла не сбрасывать ошибку. Три состояния убирают двусмысленность: undefined = нет override (readUrlId рулит), null = принудительно пусто, string = override на конкретный id. --- tradein-mvp/frontend/src/app/v2/page.tsx | 28 +++++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 7a2b16f7..3f6868ac 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -410,11 +410,22 @@ export default function TradeInV2Page() { // stale value is locked in forever (mirrors how `freshResult` already // overrides the same race for the post-submit path below). Cleared once // `restored.data` actually reflects this id (see effect below). - const [pendingRestoreId, setPendingRestoreId] = useState( - null, - ); + // + // Three states, not two: `undefined` means "no override, defer to + // readUrlId()"; `null` means "override to no-id" (handleNew's reset); + // a string means "override to this id" (a row was just clicked). Using + // plain `null` for both "no override" and "force no-id" was tried first and + // reintroduced this exact race for handleNew(): forcing pendingRestoreId + // from a bad id to `null` would, under `pendingRestoreId ?? readUrlId()`, + // fall through to readUrlId() on the very next render — which is still + // stale until router.replace("/v2") finishes — so the bad id came right + // back. The two intents need distinguishable values. + const [pendingRestoreId, setPendingRestoreId] = useState< + string | null | undefined + >(undefined); - const urlId = pendingRestoreId ?? readUrlId(); + const urlId = + pendingRestoreId !== undefined ? pendingRestoreId : readUrlId(); // Post-mount flag: readUrlId() is null on SSR but a UUID on the client's first // render, so any structure that depends on the id (e.g. the PDF vs disabled @@ -486,11 +497,12 @@ export default function TradeInV2Page() { // what the URL will become — rather than risk reverting to a stale read. useEffect(() => { if ( + pendingRestoreId !== undefined && pendingRestoreId !== null && restored.data?.estimate_id === pendingRestoreId && readUrlId() === pendingRestoreId ) { - setPendingRestoreId(null); + setPendingRestoreId(undefined); } }, [pendingRestoreId, restored.data]); @@ -674,7 +686,8 @@ export default function TradeInV2Page() { setFreshResult(est); // A fresh submission always supersedes any pending restore-by-id // override (#2424 fix) — drop it so it can't shadow a later restore. - setPendingRestoreId(null); + // `undefined` (not `null`): there is no override to force, just none. + setPendingRestoreId(undefined); // A new estimate consumed quota and added a history row — refresh both // so the TopNav badge + «Предыдущие оценки» overlay reflect it. void queryClient.invalidateQueries({ @@ -695,6 +708,9 @@ export default function TradeInV2Page() { function handleNew() { setFreshResult(null); + // `null`, not `undefined`: forces urlId to no-id immediately, synchronously + // clearing restoreActive/restoreError even before router.replace below + // finishes and readUrlId() would otherwise still read the stale id. setPendingRestoreId(null); router.replace("/v2", { scroll: false }); }