Appearance
SPEC: Оптимизация Undo при Drag-and-Drop (Атомарность перетаскивания)
Ticket: BACKLOG-BUG-001 Priority: Высокий (UX-критический баг) Estimated Effort: ~2-3 часа
1. Objective
Сделать любое перетаскивание (drag) одним атомарным шагом в истории Undo.
Сейчас одно перетаскивание точки генерирует десятки записей в history stack — пользователю приходится нажимать Undo 10-30 раз, чтобы отменить одно действие. Это делает Undo бесполезным.
Целевые пользователи
Любой пользователь, строящий маршрут и использующий Ctrl+Z / кнопку Undo.
2. Root Cause Analysis
Источники проблемы (два независимых бага):
Bug A: Waypoint Marker Drag (🔴 Нет pause/resume вообще)
Файл: useWaypointMarkers.ts:133-146
Маркеры Start/Finish/промежуточные точки — draggable: true. При перетаскивании MapLibre вызывает dragend → updateWaypoint() + recalculateRoute(). Но нет temporal.pause() на dragstart и нет temporal.resume() на dragend.
Каждый промежуточный updateWaypoint (от MapLibre internal drag events) записывается в history stack.
Bug B: Route-Line Drag (🟡 Resume вызывается слишком рано)
Файл: useDragInteractions.ts:259-268
Route-line rubber-band drag правильно вызывает pause() на mousedown (строка 196). Но resume() вызывается на mouseup (строка 260-262) перед recalculateRoute() (строка 268).
Это означает:
resume()— undo recording is ONrecalculateRoute()→ async →setRoute(newResult)→ записывается как отдельный шаг- Если пересчёт маршрута вызывает несколько
set()(waypoints + route), каждый попадёт в историю
3. Solution Design
Принцип: "Snapshot-based undo"
Вместо записи каждого промежуточного set(), мы:
- Pause tracking на
dragstart - Выполняем все мутации (updateWaypoint, recalculateRoute)
- Resume tracking только после финального
setRoute()отrecalculateRoute()
Паттерн реализации (zundo API):
typescript
// На dragstart:
usePlannerStore.temporal.getState().pause()
// На dragend (после всех мутаций завершены):
usePlannerStore.temporal.getState().resume()Ключевой момент: resume() должен вызываться после завершения recalculateRoute(), а не до.
4. Tasks
Task 1: Fix Waypoint Marker Drag (Bug A)
Файл: apps/web/src/widgets/map-canvas/hooks/useWaypointMarkers.ts
diff
marker.on('dragstart', () => {
el.dataset.isDragging = 'true'
+ usePlannerStore.temporal.getState().pause()
})
marker.on('dragend', () => {
delete el.dataset.isDragging
const lngLat = marker.getLngLat()
const idx = Number.parseInt(marker.getElement().dataset.originalIndex || '-1', 10)
if (idx >= 0) {
usePlannerStore.getState().updateWaypoint(idx, { lng: lngLat.lng, lat: lngLat.lat })
- recalculateRoute()
+ recalculateRoute().finally(() => {
+ usePlannerStore.temporal.getState().resume()
+ })
}
+ else {
+ usePlannerStore.temporal.getState().resume()
+ }
})Acceptance Criteria:
- [ ] Перетаскивание Start/Finish маркера = 1 запись в Undo
- [ ] Перетаскивание промежуточной точки = 1 запись в Undo
- [ ] При неудачном drag (idx < 0) resume всё равно вызывается
Task 2: Fix Route-Line Drag Resume Timing (Bug B)
Файл: apps/web/src/widgets/map-canvas/hooks/useDragInteractions.ts
diff
const onGlobalMouseUp = () => {
if (draggedRoutePointIdxRef.current === null) return
clearRubberband()
dragStateRef.current = null
draggedRoutePointIdxRef.current = null
map.dragPan.enable()
map.getCanvas().style.cursor = ''
- // Resume undo history
- usePlannerStore.temporal.getState().resume()
;(map as unknown as { _justFinishedRouteDrag?: boolean })._justFinishedRouteDrag = true
- recalculateRoute()
+ // Resume undo history AFTER recalculation completes
+ recalculateRoute().finally(() => {
+ usePlannerStore.temporal.getState().resume()
+ })
}Acceptance Criteria:
- [ ] Rubber-band drag + route recalculation = 1 запись в Undo
- [ ] resume() вызывается даже если recalculateRoute() завершится с ошибкой (
.finally())
Task 3: Verify recalculateRoute() returns Promise
Файл: apps/web/src/features/build-route/model/routeOrchestrator.ts
Проверить что recalculateRoute() возвращает Promise (или может быть обёрнут в .finally()).
Если функция синхронная или void — обернуть возврат:
typescript
export async function recalculateRoute(): Promise<void> {
// ... existing logic
}Acceptance Criteria:
- [ ]
recalculateRoute()возвращаетPromise<void> - [ ]
.finally()корректно вызывается при успехе и при ошибке
Task 4: Unit Tests
Файл: apps/web/src/features/undo-redo/ui/__tests__/undoAtomicity.test.ts (новый)
typescript
describe('Undo atomicity for drag operations', () => {
it('waypoint marker drag creates exactly 1 undo entry', () => {
// Setup: 2 waypoints + route
// Action: simulate drag (updateWaypoint + setRoute)
// Assert: temporal.getState().pastStates.length increased by 1
})
it('route-line drag creates exactly 1 undo entry', () => {
// Setup: 2 waypoints + route
// Action: simulate insertWaypoint + updateWaypoint + setRoute
// Assert: temporal.getState().pastStates.length increased by 1
})
it('undo after drag restores exact previous state', () => {
// Setup: 2 waypoints + route
// Action: drag waypoint → setRoute
// Action: temporal.undo()
// Assert: state matches pre-drag snapshot
})
})Acceptance Criteria:
- [ ] Тест проверяет что 1 drag = 1 undo entry
- [ ] Тест проверяет что undo после drag восстанавливает предыдущее состояние
5. Testing Strategy
| Type | What | How |
|---|---|---|
| Unit | Atomicity guarantee | Assert pastStates.length increments by 1 per drag |
| Unit | Resume safety | Assert resume called even on error |
| Manual | Waypoint drag | Drag Start marker → Undo once → marker returns |
| Manual | Route drag | Rubberband reshape → Undo once → route restores |
| Regression | Existing undo | Verify addWaypoint, removeWaypoint, setProfile still work |
6. Boundaries
Always Do:
- Call
pause()synchronously on dragstart - Call
resume()in.finally()to guarantee execution - Keep existing
partializeandequalityconfig unchanged
Ask First:
- Whether to add
wrapTracks: trueto zundo config (records undo entries onresume()) - Whether to implement smart viewport (Task 3.1 from use-case) in this ticket or defer
Never Do:
- Don't replace zundo with custom history — existing integration is solid
- Don't change the
equalityfunction — it correctly prevents duplicate entries - Don't add debounce to
updateWaypoint— it would break rubber-band visual feedback - Don't modify
recalculateRouteinternals — only ensure it returns Promise
7. Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
resume() never called (drag interrupted) | Low | High (undo permanently broken) | .finally() guarantees call; add safety timeout |
recalculateRoute doesn't return Promise | Medium | Medium | Verify in Task 3; wrap if needed |
| Existing tests break | Low | Low | Only touching drag event handlers, not store logic |
8. Out of Scope (Deferred)
These items from use-case #004 are not in this ticket:
- ❌ Smart Viewport (камера при Undo) → separate ticket
- ❌ Анимация метрик при Undo → cosmetic, defer
- ❌ AbortController для pending routes → separate ticket
- ❌ "Clear Route" в стек Undo → separate ticket
Фокус этого тикета: минимальный хирургический фикс — 2 файла, ~15 строк изменений.