8d752e0bd8
The previous guard compared the pinned design against the live activeDesignId (the selection). On a design switch the selection flips synchronously while the new canvas loads asynchronously, so the on-screen nodes briefly still belong to the previous design — arming the timer with the newly-selected id would save those stale nodes under the wrong design. Track the design the canvas was actually loaded as (loadedDesignId provenance) and gate autosave on that instead: pin it when the timer is armed and re-check the live provenance at fire time, skipping the save if a different canvas has since loaded. Also validate the persisted `enabled` flag by type (mirror of `delay`), and add the matching regression tests (provenance switch skip, enabled type validation). ha-relevant: maybe
73 lines
2.9 KiB
TypeScript
73 lines
2.9 KiB
TypeScript
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 actually belongs to — its *provenance*, set
|
|
* when a canvas is loaded, NOT the currently-selected design. These differ
|
|
* during a design switch: the selection flips synchronously while the new
|
|
* canvas loads asynchronously, so for a brief window the on-screen nodes still
|
|
* belong to the previous design. Gating on provenance (not selection) is what
|
|
* prevents saving one design's canvas under another design's id.
|
|
*/
|
|
designId: 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. Its length MUST stay constant across renders (React requires a
|
|
* stable dependency-array size) — pass a fixed-shape array like [nodes, edges].
|
|
*/
|
|
changeSignals: readonly unknown[]
|
|
/**
|
|
* Reads the *live* canvas provenance at fire time. If it no longer matches the
|
|
* id pinned when the timer was armed, a different canvas has since loaded, so
|
|
* the save is skipped rather than written under the wrong (now-stale) id.
|
|
*/
|
|
getLiveDesignId: () => 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,
|
|
designId,
|
|
changeSignals,
|
|
getLiveDesignId,
|
|
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 getLiveDesignIdRef = useRef(getLiveDesignId)
|
|
useEffect(() => {
|
|
onSaveRef.current = onSave
|
|
getLiveDesignIdRef.current = getLiveDesignId
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (!enabled || !hasUnsavedChanges || !designId) return
|
|
const pinnedDesignId = designId
|
|
const t = setTimeout(() => {
|
|
// Skip if a different canvas has loaded while the timer was pending.
|
|
if (getLiveDesignIdRef.current() !== pinnedDesignId) return
|
|
onSaveRef.current(pinnedDesignId)
|
|
}, delaySeconds * 1000)
|
|
return () => clearTimeout(t)
|
|
// changeSignals is spread so any canvas edit resets the debounce; its length
|
|
// must stay constant (documented on the option).
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [enabled, delaySeconds, hasUnsavedChanges, designId, ...changeSignals])
|
|
}
|