diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index d6a2eca1..c82f2a43 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -401,8 +401,20 @@ export default function TradeInV2Page() { const [freshResult, setFreshResult] = useState( null, ); + // #2424 fix — router.replace(`?id=`) is an async client-navigation (RSC + // round-trip) that does NOT update window.location.search synchronously, but + // the onSelectEstimate handler below also calls setNav(0) in the same tick, + // which DOES force an immediate synchronous re-render. Without an override, + // that re-render's readUrlId() call reads the stale (pre-navigation) URL — + // and since this page has no other reactive subscription to the URL, the + // 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, + ); - const urlId = readUrlId(); + const urlId = 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 @@ -459,6 +471,29 @@ export default function TradeInV2Page() { ? null : (freshResult?.estimate_id ?? urlId); + // Clear the pendingRestoreId override once BOTH (a) the restore query has + // resolved data for that same id AND (b) window.location.search has + // genuinely caught up to it — only then is readUrlId() alone guaranteed to + // keep returning the right value, so the override is no longer needed. + // The (b) check guards a narrow edge case: if the query cache already held + // a fresh (within staleTime) response for the clicked id — e.g. it was the + // just-submitted/just-restored estimate earlier in the same SPA session — + // `restored.data` can resolve synchronously, before router.replace's async + // navigation has actually updated the URL. Clearing on (a) alone would then + // drop the override a beat too early and briefly re-expose the very race + // this state exists to prevent. If (b) isn't true yet, we simply leave the + // (still-correct) override in place — harmless, since it already matches + // what the URL will become — rather than risk reverting to a stale read. + useEffect(() => { + if ( + pendingRestoreId !== null && + restored.data?.estimate_id === pendingRestoreId && + readUrlId() === pendingRestoreId + ) { + setPendingRestoreId(null); + } + }, [pendingRestoreId, restored.data]); + // Per-user side data, independent of the current estimate. Both fail-soft // (retry:false): history feeds the «Предыдущие оценки» overlay (04), quota // feeds the TopNav «Мои отчёты» badge. Refreshed after a successful estimate @@ -637,6 +672,9 @@ export default function TradeInV2Page() { mutation.mutate(input, { onSuccess: (est) => { 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); // A new estimate consumed quota and added a history row — refresh both // so the TopNav badge + «Предыдущие оценки» overlay reflect it. void queryClient.invalidateQueries({ @@ -925,6 +963,19 @@ export default function TradeInV2Page() { // above (line ~649); this file uses replace() consistently for // every /v2?id= transition, so we follow that convention here // too rather than introducing the file's only push() call. + // + // #2424 fix: router.replace() is an async client-navigation — + // window.location.search does NOT update synchronously — but + // setNav(0) below DOES force an immediate synchronous + // re-render in the same tick. Without pendingRestoreId, + // readUrlId() on that re-render would read the stale + // (pre-navigation) URL and — since this page has no other + // reactive subscription to the URL — that stale value would + // be locked in forever. setPendingRestoreId synchronously + // hands `urlId` the correct target id, mirroring how + // `freshResult` already overrides this same race for the + // post-submit path (handleSubmit above). + setPendingRestoreId(id); router.replace(`/v2?id=${id}`, { scroll: false }); setNav(0); }}