feat(site-finder): debounce weight re-analyze (#201 Phase 2) #215

Merged
lekss361 merged 1 commit from feat/201-debounce-weights into main 2026-05-16 12:54:51 +00:00
Owner

Summary

  • useDebouncedValue<T>(value, delayMs) hook (NEW, 19 LOC) — generic debounce
  • В site-finder/page.tsx: replace direct mutate в handleWeightsChangependingWeightsChange state + useDebouncedValue(pendingWeightsChange, 300) + useEffect для fire mutate после 300ms тишины
  • "Пересчёт…" loading indicator при isPending && !!data

Race condition

useMutation (не useQuery) — last settled mutation wins. dataRef/profileUserIdRef через ref предотвращают stale closure без re-register timeout на каждом analysis completion.

Test plan

  • Browser: site-finder → analyze → BestLayoutsBlock weight sliders → "Применить" multiple rapid clicks → Network shows ~1 request per 300ms burst
  • "Пересчёт…" indicator visible во время re-analyze
  • Slider visual movement instant (no debounce on UI)

Pre-push checks

  • tsc: 0 errors
  • lint: 0 warnings
  • prettier: pass

Closes #201

## Summary - `useDebouncedValue<T>(value, delayMs)` hook (NEW, 19 LOC) — generic debounce - В `site-finder/page.tsx`: replace direct `mutate` в `handleWeightsChange` → `pendingWeightsChange` state + `useDebouncedValue(pendingWeightsChange, 300)` + `useEffect` для fire mutate после 300ms тишины - "Пересчёт…" loading indicator при `isPending && !!data` ## Race condition `useMutation` (не `useQuery`) — last settled mutation wins. `dataRef`/`profileUserIdRef` через ref предотвращают stale closure без re-register timeout на каждом analysis completion. ## Test plan - [ ] Browser: site-finder → analyze → BestLayoutsBlock weight sliders → "Применить" multiple rapid clicks → Network shows ~1 request per 300ms burst - [ ] "Пересчёт…" indicator visible во время re-analyze - [ ] Slider visual movement instant (no debounce on UI) ## Pre-push checks - tsc: 0 errors - lint: 0 warnings - prettier: pass Closes #201
lekss361 added 1 commit 2026-05-16 12:48:29 +00:00
Add useDebouncedValue hook; replace direct mutate call in handleWeightsChange
with debounced pendingWeightsChange state to collapse rapid 'Применить' clicks
into a single /analyze request. Add 'Пересчёт…' indicator during re-analyze.

Closes #201
Author
Owner

Code Review verdict

Summary

  • Status: APPROVE
  • Files reviewed: 2 (frontend-only)
  • Lines: +83 / -12
  • Scope: frontend/src/hooks/** + frontend/src/app/site-finder/** — ALLOWED for auto-merge per .claude/rules/git-pr.md

Critical issues (BLOCK push)

None.

Minor observations (non-blocking)

  • useDebouncedValue + delayMs dep (useDebouncedValue.ts:16) — including delayMs in deps means a runtime change to the delay resets the timer. Correct for the hook contract (the only caller passes a constant 300), just noting.
  • In-flight mutate not cancelled (page.tsx:181) — debounce collapses rapid bursts (good), but if user triggers two re-analyzes separated by >300ms, the first request continues. TanStack useMutation "last settled wins" still applies for mutation.data, so the UI converges correctly — но при долгом /analyze (5–10s) теоретически возможно кратко увидеть stale данные от первого request. PR body уже это документирует; current behavior is acceptable.
  • dataRef.current = data during render (page.tsx:165, 167) — works (refs are mutable, no concurrent-mode hazard since not used as state-of-truth), но slightly non-idiomatic vs useEffect(() => { ref.current = data }). Не блокер — комментарий поясняет намерение.
  • Initial-mount guard double-checked OKweightsChangeInitializedRef blocks first run, и даже если React 19 dev StrictMode double-invokes, if (!debouncedWeightsChange) return ловит второй проход (initial pendingWeightsChange === null). Safe.

Positive observations

  • Generic useDebouncedValue<T> правильно типизирован, cleanup clearTimeout корректен, deps массив минимален.
  • pendingWeightsChange state strict-typed ({ weights: Record<PoiCategoryKey, number>; profileId: number | null } | null) — no any.
  • "Пересчёт…" indicator condition isPending && !!data корректно разделяет initial analyze (CadInput loading) и re-analyze (мелкий hint под панелью весов).
  • eslint-disable-next-line сопровождён explanation (mutate is stable from useMutation) — соответствует принятой практике.
  • Hook generic enough для reuse в других местах (search input, filter inputs) — приятный side-benefit.
  • Backward-compat: handleAnalyze (первичный analyze) unchanged — debounce затрагивает только weight-change path.
  • Merge готов (после прохождения CI). Никаких изменений запрашивать не нужно.
  • Optional: можно вынести 300 в named const WEIGHTS_DEBOUNCE_MS рядом с компонентом, но это micro-nit.
## Code Review verdict <!-- gendesign-review-bot: sha=1e198ef verdict=approve --> ### Summary - Status: APPROVE - Files reviewed: 2 (frontend-only) - Lines: +83 / -12 - Scope: `frontend/src/hooks/**` + `frontend/src/app/site-finder/**` — ALLOWED for auto-merge per `.claude/rules/git-pr.md` ### Critical issues (BLOCK push) None. ### Minor observations (non-blocking) - **`useDebouncedValue` + `delayMs` dep** (`useDebouncedValue.ts:16`) — including `delayMs` in deps means a runtime change to the delay resets the timer. Correct for the hook contract (the only caller passes a constant `300`), just noting. - **In-flight mutate not cancelled** (`page.tsx:181`) — debounce collapses rapid bursts (good), but if user triggers two re-analyzes separated by >300ms, the first request continues. TanStack `useMutation` "last settled wins" still applies for `mutation.data`, so the UI converges correctly — но при долгом /analyze (5–10s) теоретически возможно кратко увидеть stale данные от первого request. PR body уже это документирует; current behavior is acceptable. - **`dataRef.current = data` during render** (`page.tsx:165, 167`) — works (refs are mutable, no concurrent-mode hazard since not used as state-of-truth), но slightly non-idiomatic vs `useEffect(() => { ref.current = data })`. Не блокер — комментарий поясняет намерение. - **Initial-mount guard double-checked OK** — `weightsChangeInitializedRef` blocks first run, и даже если React 19 dev StrictMode double-invokes, `if (!debouncedWeightsChange) return` ловит второй проход (initial `pendingWeightsChange === null`). Safe. ### Positive observations - Generic `useDebouncedValue<T>` правильно типизирован, cleanup `clearTimeout` корректен, deps массив минимален. - `pendingWeightsChange` state strict-typed (`{ weights: Record<PoiCategoryKey, number>; profileId: number | null } | null`) — no `any`. - "Пересчёт…" indicator condition `isPending && !!data` корректно разделяет initial analyze (CadInput loading) и re-analyze (мелкий hint под панелью весов). - `eslint-disable-next-line` сопровождён explanation (`mutate is stable from useMutation`) — соответствует принятой практике. - Hook generic enough для reuse в других местах (search input, filter inputs) — приятный side-benefit. - Backward-compat: `handleAnalyze` (первичный analyze) unchanged — debounce затрагивает только weight-change path. ### Recommended next steps - Merge готов (после прохождения CI). Никаких изменений запрашивать не нужно. - Optional: можно вынести `300` в named const `WEIGHTS_DEBOUNCE_MS` рядом с компонентом, но это micro-nit.
lekss361 merged commit ce2c48fff5 into main 2026-05-16 12:54:51 +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#215
No description provided.