fix(canvas): guard autosave against design-switch clobber, add tests
Extract the debounced autosave into a useAutosave hook. Pin the active design id when the timer is armed and re-check it at fire time: if the user switched designs while the timer was pending, the in-memory canvas belongs to a different design, so skip the save instead of writing it under the wrong design id. Also skip when there is no active design. Add unit tests for autosaveSettings (persistence, validation, pub/sub) and useAutosave (debounce, enable/unsaved/design guards, switch skip). ha-relevant: maybe
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useAutosave } from '../useAutosave'
|
||||
|
||||
interface Args {
|
||||
enabled?: boolean
|
||||
delaySeconds?: number
|
||||
hasUnsavedChanges?: boolean
|
||||
activeDesignId?: string | null
|
||||
changeSignals?: unknown[]
|
||||
getActiveDesignId?: () => string | null
|
||||
onSave?: (designId: string) => void
|
||||
}
|
||||
|
||||
function args(over: Args = {}) {
|
||||
const activeDesignId = 'activeDesignId' in over ? (over.activeDesignId ?? null) : 'design-a'
|
||||
return {
|
||||
enabled: over.enabled ?? true,
|
||||
delaySeconds: over.delaySeconds ?? 5,
|
||||
hasUnsavedChanges: over.hasUnsavedChanges ?? true,
|
||||
activeDesignId,
|
||||
changeSignals: over.changeSignals ?? [[], []],
|
||||
getActiveDesignId: over.getActiveDesignId ?? (() => activeDesignId),
|
||||
onSave: over.onSave ?? vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('useAutosave', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('saves after the inactivity delay when enabled with unsaved changes', () => {
|
||||
const onSave = vi.fn()
|
||||
renderHook(() => useAutosave(args({ onSave, delaySeconds: 5 })))
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(4999)
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(onSave).toHaveBeenCalledExactlyOnceWith('design-a')
|
||||
})
|
||||
|
||||
it('does nothing when disabled', () => {
|
||||
const onSave = vi.fn()
|
||||
renderHook(() => useAutosave(args({ onSave, enabled: false })))
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when there are no unsaved changes', () => {
|
||||
const onSave = vi.fn()
|
||||
renderHook(() => useAutosave(args({ onSave, hasUnsavedChanges: false })))
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when there is no active design', () => {
|
||||
const onSave = vi.fn()
|
||||
renderHook(() => useAutosave(args({ onSave, activeDesignId: null })))
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('debounces: a change before the delay resets the timer', () => {
|
||||
const onSave = vi.fn()
|
||||
const { rerender } = renderHook((p: Args) => useAutosave(args(p)), {
|
||||
initialProps: { onSave, changeSignals: [1] },
|
||||
})
|
||||
vi.advanceTimersByTime(4000)
|
||||
rerender({ onSave, changeSignals: [2] }) // edit resets debounce
|
||||
vi.advanceTimersByTime(4000)
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(onSave).toHaveBeenCalledExactlyOnceWith('design-a')
|
||||
})
|
||||
|
||||
it('skips the save if the active design changed while the timer was pending', () => {
|
||||
const onSave = vi.fn()
|
||||
// Pinned at arm time = 'design-a', but live id has moved on to 'design-b'.
|
||||
renderHook(() =>
|
||||
useAutosave(args({ onSave, activeDesignId: 'design-a', getActiveDesignId: () => 'design-b' })),
|
||||
)
|
||||
vi.advanceTimersByTime(5000)
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves under the pinned design id, not the live one, when they still match', () => {
|
||||
const onSave = vi.fn()
|
||||
renderHook(() =>
|
||||
useAutosave(args({ onSave, activeDesignId: 'design-a', getActiveDesignId: () => 'design-a' })),
|
||||
)
|
||||
vi.advanceTimersByTime(5000)
|
||||
expect(onSave).toHaveBeenCalledExactlyOnceWith('design-a')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface UseAutosaveOptions {
|
||||
/** Whether autosave is enabled (user opt-in). */
|
||||
enabled: boolean
|
||||
/** Inactivity delay in seconds before firing a save. */
|
||||
delaySeconds: number
|
||||
/** True when the canvas has edits not yet persisted. */
|
||||
hasUnsavedChanges: boolean
|
||||
/** The design the in-memory canvas currently belongs to. */
|
||||
activeDesignId: string | null
|
||||
/**
|
||||
* Values that represent canvas edits (e.g. nodes, edges). Any change to one
|
||||
* of these resets the debounce timer, so the save only fires after a quiet
|
||||
* period. Kept separate from the trigger flags so the caller controls exactly
|
||||
* what counts as "activity".
|
||||
*/
|
||||
changeSignals: unknown[]
|
||||
/**
|
||||
* Reads the *live* active design id at fire time. Used to detect that the user
|
||||
* switched designs while the timer was pending — if so, the in-memory canvas
|
||||
* now belongs to a different design and saving it under the pinned id would
|
||||
* clobber the wrong design, so the save is skipped.
|
||||
*/
|
||||
getActiveDesignId: () => string | null
|
||||
/** Persist the canvas under the given design id. */
|
||||
onSave: (designId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced canvas autosave. Fires `onSave(designId)` after `delaySeconds` of
|
||||
* inactivity when enabled and there are unsaved changes. Opt-in only — the
|
||||
* caller decides whether `enabled` is set (see ADR: autosave defaults to off).
|
||||
*/
|
||||
export function useAutosave({
|
||||
enabled,
|
||||
delaySeconds,
|
||||
hasUnsavedChanges,
|
||||
activeDesignId,
|
||||
changeSignals,
|
||||
getActiveDesignId,
|
||||
onSave,
|
||||
}: UseAutosaveOptions): void {
|
||||
// Keep the latest callbacks in refs so the timer always calls the current
|
||||
// versions without re-arming (which would reset the debounce) on every render.
|
||||
const onSaveRef = useRef(onSave)
|
||||
const getActiveDesignIdRef = useRef(getActiveDesignId)
|
||||
useEffect(() => {
|
||||
onSaveRef.current = onSave
|
||||
getActiveDesignIdRef.current = getActiveDesignId
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !hasUnsavedChanges || !activeDesignId) return
|
||||
const designId = activeDesignId
|
||||
const t = setTimeout(() => {
|
||||
// Skip if the active design changed while the timer was pending.
|
||||
if (getActiveDesignIdRef.current() !== designId) return
|
||||
onSaveRef.current(designId)
|
||||
}, delaySeconds * 1000)
|
||||
return () => clearTimeout(t)
|
||||
// changeSignals is spread so any canvas edit resets the debounce.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, delaySeconds, hasUnsavedChanges, activeDesignId, ...changeSignals])
|
||||
}
|
||||
Reference in New Issue
Block a user