'use client' import { useScene } from '@pascal-app/core' import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' import { type SceneGraph, saveSceneToLocalStorage } from '../lib/scene' const AUTOSAVE_DEBOUNCE_MS = 1000 export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error' interface UseAutoSaveOptions { onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise onDirty?: () => void onSaveStatusChange?: (status: SaveStatus) => void isVersionPreviewMode?: boolean } /** * Generic autosave hook. Subscribes to the scene store and debounces saves. * Falls back to localStorage when no `onSave` is provided. * * ⚠️ Mount in exactly ONE component (the Editor). */ export function useAutoSave({ onSave, onDirty, onSaveStatusChange, isVersionPreviewMode = false, }: UseAutoSaveOptions): { isLoadingSceneRef: MutableRefObject } { const saveTimeoutRef = useRef(undefined) const isSavingRef = useRef(false) const isLoadingSceneRef = useRef(false) const pendingSaveRef = useRef(false) const executeSaveRef = useRef<(() => Promise) | null>(null) const hasDirtyChangesRef = useRef(false) // Keep latest callback/value refs so the stable subscription always uses current values const onSaveRef = useRef(onSave) const onDirtyRef = useRef(onDirty) const onSaveStatusChangeRef = useRef(onSaveStatusChange) const isVersionPreviewModeRef = useRef(isVersionPreviewMode) useEffect(() => { onSaveRef.current = onSave }, [onSave]) useEffect(() => { onDirtyRef.current = onDirty }, [onDirty]) useEffect(() => { onSaveStatusChangeRef.current = onSaveStatusChange }, [onSaveStatusChange]) useEffect(() => { isVersionPreviewModeRef.current = isVersionPreviewMode }, [isVersionPreviewMode]) const setSaveStatus = useCallback((status: SaveStatus) => { onSaveStatusChangeRef.current?.(status) }, []) // Stable subscription to scene changes useEffect(() => { let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes) let lastNodeCount = Object.keys(useScene.getState().nodes).length async function executeSave() { if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) { pendingSaveRef.current = true setSaveStatus('paused') return } const { nodes, rootNodeIds } = useScene.getState() const sceneGraph = { nodes, rootNodeIds } as SceneGraph // Guard: refuse to autosave if the scene went from populated to nearly empty. // This catches accidental full deletions before they're persisted. const currentNodeCount = Object.keys(nodes).length const STRUCTURAL_NODE_COUNT = 4 // site + building + levels (empty scene skeleton) if (lastNodeCount > STRUCTURAL_NODE_COUNT && currentNodeCount <= STRUCTURAL_NODE_COUNT) { console.warn( `[autosave] Blocked: scene dropped from ${lastNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`, ) setSaveStatus('error') return } lastNodeCount = currentNodeCount isSavingRef.current = true pendingSaveRef.current = false setSaveStatus('saving') try { if (onSaveRef.current) { await onSaveRef.current(sceneGraph) } else { saveSceneToLocalStorage(sceneGraph) } hasDirtyChangesRef.current = false setSaveStatus('saved') } catch { setSaveStatus('error') } finally { isSavingRef.current = false if (pendingSaveRef.current) { pendingSaveRef.current = false setSaveStatus('pending') saveTimeoutRef.current = setTimeout(() => { saveTimeoutRef.current = undefined executeSave() }, AUTOSAVE_DEBOUNCE_MS) } } } executeSaveRef.current = executeSave const unsubscribe = useScene.subscribe((state) => { if (isLoadingSceneRef.current) { lastNodesSnapshot = JSON.stringify(state.nodes) return } if (isVersionPreviewModeRef.current) { setSaveStatus('paused') lastNodesSnapshot = JSON.stringify(state.nodes) return } const currentNodesSnapshot = JSON.stringify(state.nodes) if (currentNodesSnapshot === lastNodesSnapshot) return lastNodesSnapshot = currentNodesSnapshot hasDirtyChangesRef.current = true onDirtyRef.current?.() setSaveStatus('pending') if (isSavingRef.current) { pendingSaveRef.current = true return } if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current) saveTimeoutRef.current = setTimeout(() => { saveTimeoutRef.current = undefined executeSave() }, AUTOSAVE_DEBOUNCE_MS) }) // Flush any unsaved change while the page is going away. The network // save MUST set `keepalive` — a normal fetch is cancelled by the browser // the moment the page unloads, so a quick refresh right after an edit // would otherwise drop the change entirely. `pagehide` fires in cases // (mobile Safari, bfcache) where `beforeunload` does not. function flushOnExit() { if (!hasDirtyChangesRef.current) return hasDirtyChangesRef.current = false const { nodes, rootNodeIds } = useScene.getState() const sceneGraph = { nodes, rootNodeIds } as SceneGraph if (onSaveRef.current) { onSaveRef.current(sceneGraph, { keepalive: true }).catch(() => {}) } else { saveSceneToLocalStorage(sceneGraph) } } window.addEventListener('beforeunload', flushOnExit) window.addEventListener('pagehide', flushOnExit) return () => { executeSaveRef.current = null window.removeEventListener('beforeunload', flushOnExit) window.removeEventListener('pagehide', flushOnExit) if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current) flushOnExit() unsubscribe() } }, [setSaveStatus]) // Handle version preview mode transitions useEffect(() => { if (isVersionPreviewMode) { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current) saveTimeoutRef.current = undefined } if (hasDirtyChangesRef.current) { pendingSaveRef.current = true } setSaveStatus('paused') return } if (isSavingRef.current) return if (hasDirtyChangesRef.current) { setSaveStatus('pending') if (!saveTimeoutRef.current) { saveTimeoutRef.current = setTimeout(() => { saveTimeoutRef.current = undefined executeSaveRef.current?.() }, AUTOSAVE_DEBOUNCE_MS) } return } setSaveStatus('saved') }, [isVersionPreviewMode, setSaveStatus]) return { isLoadingSceneRef } }