fix(tradein/v2): гонка при клике по строке «Предыдущие оценки» — отчёт не загружался #2428

Merged
lekss361 merged 4 commits from fix/tradein-cacheview-restore-race into main 2026-07-04 14:47:02 +00:00
Owner

Summary

Живьём воспроизведено на проде (Playwright, авторизованная сессия): клик по строке в оверлее «Предыдущие оценки» (feature из #2424) обновлял URL на ?id=<uuid>, закрывал оверлей — но АДРЕС/ОТЧЁТ/ДАТА оставались «—», запроса на загрузку оценки не было вовсе (network tab пуст).

Root cause: router.replace(\?id=...`)— асинхронный client-side nav (RSC round-trip),window.location.searchНЕ обновляется синхронно.readUrlId()вpage.tsxчитаетwindow.location.search напрямую (useSearchParamsнамеренно не используется — ломаетnext buildчерез Suspense, см. комментарий в коде). Клик-хендлер тут же зовётsetNav(0), что форсирует синхронный ре-рендер в том же тике — раньше, чем завершится асинхронный router.replace. На этом ре-рендере readUrlId()читает ещё старый URL →urlId = nullrestoreActive = false` → запрос на оценку не срабатывает. Компонент не подписан на изменения URL реактивно, поэтому это состояние замирает навсегда.

Fix (после 3 раундов adversarial review):

  1. Добавлен pendingRestoreId — синхронный override, выставляемый в том же обработчике клика, что и router.replace, так что рендер сразу получает верный id, не дожидаясь readUrlId().
  2. Round-1 review нашёл: handleNew() («НОВАЯ ОЦЕНКА» после 404) не сбрасывал pendingRestoreId → permanent lock после клика по битой ссылке.
  3. Round-2 review нашёл: сброс через null в handleNew() конфликтовал с pendingRestoreId ?? readUrlId()null там означал одновременно «нет override» и «принудительно пусто», так что после handleNew() следующий рендер тут же проваливался обратно в readUrlId(), который на тот момент всё ещё возвращал старый плохой id.
  4. Этот PR: три состояния вместо двух — undefined (нет override) / null (принудительно пусто) / string (override на id). Устраняет двусмысленность полностью — проверено вручную пошагово для обоих путей (клик по валидной строке, handleNew() после 404).

Test plan

  • Живой repro на проде ДО фикса (Playwright, подтверждён баг — см. описание)
  • tsc --noEmit чистый
  • Live-verify ПОСЛЕ деплоя: клик по строке истории → отчёт загружается (АДРЕС/ПАРАМЕТРЫ не «—», сетевой запрос на оценку есть)
  • Live-verify: клик по битой/404 строке → «НОВАЯ ОЦЕНКА» → форма чистая, ошибка не залипает

Refs #2424

## Summary Живьём воспроизведено на проде (Playwright, авторизованная сессия): клик по строке в оверлее «Предыдущие оценки» (feature из #2424) обновлял URL на `?id=<uuid>`, закрывал оверлей — но АДРЕС/ОТЧЁТ/ДАТА оставались «—», запроса на загрузку оценки не было вовсе (network tab пуст). **Root cause:** `router.replace(\`?id=...\`)` — асинхронный client-side nav (RSC round-trip), `window.location.search` НЕ обновляется синхронно. `readUrlId()` в `page.tsx` читает `window.location.search` напрямую (`useSearchParams` намеренно не используется — ломает `next build` через Suspense, см. комментарий в коде). Клик-хендлер тут же зовёт `setNav(0)`, что форсирует синхронный ре-рендер в том же тике — раньше, чем завершится асинхронный `router.replace`. На этом ре-рендере `readUrlId()` читает ещё старый URL → `urlId = null` → `restoreActive = false` → запрос на оценку не срабатывает. Компонент не подписан на изменения URL реактивно, поэтому это состояние замирает навсегда. **Fix (после 3 раундов adversarial review):** 1. Добавлен `pendingRestoreId` — синхронный override, выставляемый в том же обработчике клика, что и `router.replace`, так что рендер сразу получает верный id, не дожидаясь `readUrlId()`. 2. Round-1 review нашёл: `handleNew()` («НОВАЯ ОЦЕНКА» после 404) не сбрасывал `pendingRestoreId` → permanent lock после клика по битой ссылке. 3. Round-2 review нашёл: сброс через `null` в `handleNew()` конфликтовал с `pendingRestoreId ?? readUrlId()` — `null` там означал одновременно «нет override» и «принудительно пусто», так что после `handleNew()` следующий рендер тут же проваливался обратно в `readUrlId()`, который на тот момент всё ещё возвращал старый плохой id. 4. **Этот PR**: три состояния вместо двух — `undefined` (нет override) / `null` (принудительно пусто) / `string` (override на id). Устраняет двусмысленность полностью — проверено вручную пошагово для обоих путей (клик по валидной строке, `handleNew()` после 404). ## Test plan - [x] Живой repro на проде ДО фикса (Playwright, подтверждён баг — см. описание) - [x] `tsc --noEmit` чистый - [ ] Live-verify ПОСЛЕ деплоя: клик по строке истории → отчёт загружается (АДРЕС/ПАРАМЕТРЫ не «—», сетевой запрос на оценку есть) - [ ] Live-verify: клик по битой/404 строке → «НОВАЯ ОЦЕНКА» → форма чистая, ошибка не залипает Refs #2424
lekss361 added 4 commits 2026-07-04 14:45:22 +00:00
router.replace(`/v2?id=`) — асинхронный soft-nav, а setNav(0) в том же тике вызывает
синхронный re-render ДО того как window.location реально обновится → readUrlId() читает
устаревшее значение навсегда (никто больше не форсит re-render). Добавлен pendingRestoreId —
синхронный override, аналогично тому как freshResult уже обходит эту же гонку для
post-submit пути.
Иначе клик "НОВАЯ ОЦЕНКА" после ошибки восстановления (напр. 404 на битый history-id) навсегда
залипает на старом pendingRestoreId — единственный effect, который его чистит, требует
restored.data, которого в error-состоянии не будет никогда. Только F5 разлипал бы.
Живая проверка в браузере (клик по строке "Предыдущие оценки" на проде)
подтвердила: 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.
style(tradein/v2): prettier (репозиторный pre-commit не покрывает tradein-mvp/frontend)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
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 1m13s
62e2a7ba85
lekss361 merged commit cfed042712 into main 2026-07-04 14:47:02 +00:00
lekss361 deleted branch fix/tradein-cacheview-restore-race 2026-07-04 14:47:02 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2428
No description provided.