Create-then-move undo deletes the object instead of undoing the move (all object types); pending-snapshot leak on cancelled drag #194
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_whiteboard#194
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
On the whiteboard, creating an object and then moving it produces a broken undo: the first Ctrl+Z deletes the object instead of undoing the move. This affects all draggable object types (sticky, text, shape, frame, image, drawing, document, and the widget types).
Reproduction:
Root cause
Each object's create function assigns a temporary id (
nextTempId()) and wires drag-history handlers that close over that creation-timeid, e.g. increateShape(crates/hero_whiteboard_admin/static/web/js/whiteboard/objects.js):WhiteboardSync.onCreatepersists the object; the server assigns a real id andobjects.js remapId(tempId, serverId)rekeys the objects map, the Konva node id, and the stacked history actions.After that remap the drag handlers still reference the stale temp
idvia closure:dragstart->snapshotBefore(tempId)->snapshot(tempId)looks upWhiteboardObjects.getObject(tempId), which no longer exists (object is registered under the server id) -> storesundefinedin_pendingBefore[tempId].dragend->commitUpdate(tempId)->beforeisundefined-> early-returns -> noupdatehistory action is pushed.So the move is never recorded. The undo stack contains only the
createaction (whose id was remapped correctly), so the first undo deletes the object.Handlers that already read the live id at event time (
group.id()/this.id()) work correctly; the per-type create handlers that close overiddo not. The fix is to resolve the live id at event time in every drag-history handler across all object types.Also in scope (same subsystem): pending-snapshot leak on cancelled drag
WhiteboardHistory.snapshotBefore(id)stores_pendingBefore[id]; onlycommitUpdate(id)clears it. If a drag starts but never cleanly ends (window blur, ESC, tab switch, aborted drag),commitUpdatenever runs, so:_pendingBefore[id]leaks (one dead entry per cancelled drag, unbounded).commitUpdate(id)without a fresh matchingsnapshotBeforediffs against the stale snapshot, producing an undo step that spans far more change than the user's action.This affects the same dragstart/dragend pattern across kanban, mindmap, calendar, webframe, sticky, text, shape, document, and drawing. The pending snapshot should be discarded on drag-abort (e.g. window blur / visibilitychange / ESC / Konva drag abort).
Expected
Affected
crates/hero_whiteboard_admin/static/web/js/whiteboard/objects.js— drag-history handlers in the per-type create functions.crates/hero_whiteboard_admin/static/web/js/whiteboard/history.js—snapshotBefore/commitUpdate/_pendingBeforelifecycle (drag-abort cleanup).Implementation Spec for Issue #194
Objective
Fix two bugs in the Hero Whiteboard client-side undo subsystem (JS only, no backend changes):
Requirements
Files to Modify/Create
objects.js- fix closed-over id in 7 create-function drag handlers + the frame handler.kanban.js- fix closed-over id in the top-level kanban group create handler.mindmap.js- fix closed-over id in the create handler.calendar.js- fix closed-over id in the create handler.webframe.js- fix closed-over id in the history calls of the create handler (overlay-id handling unchanged).history.js- add cancelPending(id) + cancelAllPending() and expose them.tools.js- wire global drag-abort listeners (window blur, document visibilitychange, ESC keydown) to cancelAllPending().Implementation Plan
Steps 1-5 (Bug A) are independent and may run in parallel. Steps 6-7 (Bug B) are sequential with each other, independent of 1-5.
Step 1: Fix closed-over temp id in objects.js create-function drag handlers
Files:
crates/hero_whiteboard_admin/static/web/js/whiteboard/objects.jsIn each dragstart/dragend pair, replace the closed-over
idpassed to snapshotBefore(id)/commitUpdate(id) with the live id group.id(). Do not change WhiteboardSync.onUpdate/recomputeParentFrame lines, and do not change WhiteboardHistory.push({type:'create', id:id}).Functions/lines: createStickyNote (dragstart 228, dragend 231); createTextBox (338, 341); createShape (885, 888); createDocument (1777, 1780); createImage (1958, 1961); createEmoji (2078, 2081); createDrawing (2179, 2182).
Dependencies: none
Step 2: Fix the frame create handler (multi-child snapshot) in objects.js
Files:
crates/hero_whiteboard_admin/static/web/js/whiteboard/objects.jscreateFrame: line 1163 snapshotBefore(id) to snapshotBefore(group.id()); line 1199 (inside the WhiteboardHistory.batch callback) commitUpdate(id) to commitUpdate(group.id()). Lines 1177 snapshotBefore(child.id()) and 1201 commitUpdate(capturedChildren[i].node.id()) already use live ids; leave unchanged. Do not change the batch wrapper, dragmove, or sync calls.
Dependencies: none
Step 3: Fix kanban.js top-level create handler
Files:
crates/hero_whiteboard_admin/static/web/js/whiteboard/kanban.jscreateKanban dragstart line 81 snapshotBefore(id) to snapshotBefore(group.id()). dragend (83-88) calls persistKanbanMutation(group) which already does commitUpdate(group.id()) - leave it. Other kanban handlers already use group.id() live - leave unchanged.
Dependencies: none
Step 4: Fix mindmap.js and calendar.js create handlers
Files:
crates/hero_whiteboard_admin/static/web/js/whiteboard/mindmap.js,crates/hero_whiteboard_admin/static/web/js/whiteboard/calendar.jsmindmap.js create handler: dragstart line 87 snapshotBefore(id) to snapshotBefore(group.id()); dragend line 90 commitUpdate(id) to commitUpdate(group.id()). Other mindmap snapshotBefore(group.id()) calls already correct.
calendar.js create handler: dragstart line 40 snapshotBefore(id) to snapshotBefore(group.id()); dragend line 43 commitUpdate(id) to commitUpdate(group.id()). Lines 54 and 392-424 already correct.
Dependencies: none
Step 5: Fix webframe.js create handler (history calls only)
Files:
crates/hero_whiteboard_admin/static/web/js/whiteboard/webframe.jscreateWebframe: line 77 snapshotBefore(id) to snapshotBefore(group.id()); line 82 commitUpdate(id) to commitUpdate(group.id()). Leave hideOverlay(id) (line 78) and showOverlay(id, group) (line 81) using the closed-over id - the overlay map is separately remapped via WhiteboardWebframe.remapOverlay and stage overlay listeners are re-bound; do NOT touch them. Only the two history calls are the bug.
Dependencies: none
Step 6: Add cancel/clear entry points to history.js
Files:
crates/hero_whiteboard_admin/static/web/js/whiteboard/history.jsAdd near commitUpdate:
function cancelPending(id){ delete _pendingBefore[id]; }andfunction cancelAllPending(){ _pendingBefore = {}; }. _pendingBefore isvarso reassignment is valid. Add both to the returned API object alongside snapshotBefore/commitUpdate. Do not change snapshotBefore/commitUpdate/remapId/snapshot/setEnabled logic.Dependencies: none
Step 7: Wire global drag-abort to cancelAllPending in tools.js
Files:
crates/hero_whiteboard_admin/static/web/js/whiteboard/tools.jsIn init(), at the object-layer drag tracking block (~lines 315-327 where isDraggingObject is set), add abort wiring reusing isDraggingObject:
Guard all calls with typeof WhiteboardHistory !== 'undefined' && WhiteboardHistory.cancelAllPending. Never call cancelAllPending on the normal layer dragend path - only on abort signals, so legitimate moves still reach commitUpdate.
Dependencies: Step 6
Acceptance Criteria
Notes
Test Results
cargo test --workspace --lib: all 4 lib targets compiled and ran clean (admin, app, examples, sdk all "ok", 0 failed). No Rust unit tests are defined at the --lib level, so 0 tests executed; the build itself confirms no compile/regression breakage from this change.
node --check (objects, kanban, mindmap, calendar, webframe, history, tools): all ok
Note: #194 is a JS-only change (drag-history live-id resolution + pending-snapshot cancel). No JS unit harness exists in this repo; the Rust suite is run to confirm no regression and the connector/undo behavior is verified manually in-browser.
Implementation Summary
JS-only fix across the whiteboard client. No backend/RPC changes.
Bug A — create-then-move undo deleted the object
Every object/widget create function's dragstart/dragend handlers closed over the creation-time temp id; after the temp to server id remap, snapshotBefore/commitUpdate operated on a dead key, so the move was never recorded and the first undo fell through to the create (deleting the object). Fixed by resolving the live id (group.id()) at event time:
connectors.js was already correct and was used as the reference pattern; it was not modified.
Bug B — pending-snapshot leak on cancelled drag
snapshotBefore stored _pendingBefore[id] and only commitUpdate cleared it; an interrupted drag (window blur, tab switch, ESC) leaked the snapshot and could poison the next undo step.
Behavior after fix
Tests