diff --git a/packages/core/src/services/drag-session.test.ts b/packages/core/src/services/drag-session.test.ts index 02cf4d85..ab71eddb 100644 --- a/packages/core/src/services/drag-session.test.ts +++ b/packages/core/src/services/drag-session.test.ts @@ -195,6 +195,19 @@ describe('createDragSession', () => { expect(onCancel).toHaveBeenCalledTimes(1) }) + test('dispose does NOT fire onCancel (silent cleanup)', () => { + const onCommit = mock(() => {}) + const onCancel = mock(() => {}) + const scene = makeSpyScene() + const session = createDragSession(makeAction(), scene, { onCommit, onCancel }) + session.start({ point: [0, 0] }) + session.dispose() + expect(onCommit).toHaveBeenCalledTimes(0) + expect(onCancel).toHaveBeenCalledTimes(0) + expect(scene._calls.resumeHistory).toBe(1) + expect(scene._calls.restoreAll).toBe(1) + }) + test('dirty cascade fires once per id even across multiple move ticks', () => { // Register a kind with no relations — cascade returns just {startId}. registerNode(makeDef('thing')) diff --git a/packages/core/src/services/drag-session.ts b/packages/core/src/services/drag-session.ts index 615456de..b58ae6e9 100644 --- a/packages/core/src/services/drag-session.ts +++ b/packages/core/src/services/drag-session.ts @@ -40,7 +40,11 @@ export type DragSession = { /** Returns the latest draft `apply` produced (or null before first move). */ getDraft: () => Draft | null isActive: () => boolean - /** Idempotent cleanup. If active, equivalent to `cancel()`. */ + /** Idempotent cleanup. If active, restores scene state and resumes + * history, but does **not** fire `onCancel`. Use for React-effect + * teardown — onCancel would re-trigger the parent's state machine and + * break StrictMode's double-mount cycle. Esc / external aborts must + * still call `cancel()` directly. */ dispose: () => void } @@ -136,7 +140,14 @@ export function createDragSession( if (active && ctx != null) { action.cancel(ctx, scene) scene.restoreAll() - terminate(false) + // Silent terminate: no onCancel. The caller (e.g. useDragAction's + // effect cleanup) is reacting to the parent unmounting and would + // loop the state machine if onCancel re-set the parent's state. + active = false + ctx = null + draft = null + dirtyMarked = new Set() + scene.resumeHistory() } }, }