All files / src/hooks useAutoSave.ts

0% Statements 0/219
100% Branches 0/0
0% Functions 0/1
0% Lines 0/219

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220                                                                                                                                                                                                                                                                                                                                                                                                                                                       
'use client';

import { useEffect, useRef, useCallback, useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { clientLogger } from '@/lib/logging/clientLogger';

interface UseAutoSaveOptions<T> {
  /** Data to auto-save */
  data: T;
  /** Callback to perform the save operation */
  onSave: (data: T) => Promise<void>;
  /** Interval in ms for periodic saves (default: 30000 = 30 seconds) */
  interval?: number;
  /** Debounce delay in ms for change-triggered saves (default: 2000 = 2 seconds) */
  debounceDelay?: number;
  /** Whether auto-save is enabled (default: true) */
  enabled?: boolean;
  /** Key for localStorage draft storage (optional) */
  draftKey?: string;
}

interface UseAutoSaveReturn {
  /** Whether a save operation is currently in progress */
  isSaving: boolean;
  /** Timestamp of the last successful save */
  lastSaved: Date | null;
  /** Whether there are unsaved changes */
  hasChanges: boolean;
  /** Manually trigger a save */
  saveNow: () => Promise<void>;
  /** Clear any stored draft */
  clearDraft: () => void;
  /** Error from the last save attempt */
  error: Error | null;
}

/**
 * Hook for auto-saving form data with debouncing and periodic saves.
 *
 * Features:
 * - Debounced save on data changes
 * - Periodic auto-save at configurable intervals
 * - Save on component unmount
 * - Optional localStorage draft storage
 * - Change detection to avoid unnecessary saves
 *
 * @example
 * ```tsx
 * const { isSaving, lastSaved, hasChanges } = useAutoSave({
 *   data: formData,
 *   onSave: async (data) => {
 *     await api.saveDraft(data);
 *   },
 *   draftKey: 'product-form-draft',
 * });
 * ```
 */
export function useAutoSave<T>({
  data,
  onSave,
  interval = 30000,
  debounceDelay = 2000,
  enabled = true,
  draftKey,
}: UseAutoSaveOptions<T>): UseAutoSaveReturn {
  const lastSavedRef = useRef<string>('');
  const initialDataRef = useRef<string>('');
  const [isSaving, setIsSaving] = useState(false);
  const [lastSaved, setLastSaved] = useState<Date | null>(null);
  const [error, setError] = useState<Error | null>(null);

  // Track initial data for change detection - intentionally runs only on mount
  useEffect(() => {
    if (!initialDataRef.current) {
      initialDataRef.current = JSON.stringify(data);
      lastSavedRef.current = JSON.stringify(data);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const hasChanges = JSON.stringify(data) !== lastSavedRef.current;

  // Save to localStorage draft
  const saveDraft = useCallback(() => {
    if (draftKey && typeof window !== 'undefined') {
      try {
        localStorage.setItem(draftKey, JSON.stringify(data));
      } catch {
        // Ignore localStorage errors
      }
    }
  }, [data, draftKey]);

  // Clear localStorage draft
  const clearDraft = useCallback(() => {
    if (draftKey && typeof window !== 'undefined') {
      try {
        localStorage.removeItem(draftKey);
      } catch {
        // Ignore localStorage errors
      }
    }
  }, [draftKey]);

  // Main save function
  const save = useCallback(async () => {
    const serialized = JSON.stringify(data);

    // Skip if no changes
    if (serialized === lastSavedRef.current) {
      return;
    }

    setIsSaving(true);
    setError(null);

    try {
      await onSave(data);
      lastSavedRef.current = serialized;
      setLastSaved(new Date());
      clearDraft(); // Clear draft after successful save
    } catch (err) {
      const saveError = err instanceof Error ? err : new Error('Auto-save failed');
      setError(saveError);
      saveDraft(); // Save to draft on error
      clientLogger.error('Auto-save failed', saveError);
    } finally {
      setIsSaving(false);
    }
  }, [data, onSave, clearDraft, saveDraft]);

  // Debounced save for data changes
  const debouncedSave = useDebouncedCallback(save, debounceDelay);

  // Auto-save on data change
  useEffect(() => {
    if (enabled && hasChanges) {
      saveDraft(); // Always save draft immediately
      debouncedSave();
    }
  }, [data, enabled, hasChanges, debouncedSave, saveDraft]);

  // Periodic save
  useEffect(() => {
    if (!enabled || interval <= 0) return;

    const timer = setInterval(() => {
      if (hasChanges) {
        save();
      }
    }, interval);

    return () => clearInterval(timer);
  }, [save, interval, enabled, hasChanges]);

  // Save on unmount - use ref to avoid stale closure
  const dataRef = useRef(data);
  useEffect(() => {
    dataRef.current = data;
  }, [data]);

  useEffect(() => {
    return () => {
      if (enabled && draftKey && typeof window !== 'undefined') {
        // Synchronous draft save on unmount using ref for latest data
        try {
          localStorage.setItem(draftKey, JSON.stringify(dataRef.current));
        } catch {
          // Ignore localStorage errors
        }
      }
    };
  }, [enabled, draftKey]);

  // Save on page unload
  useEffect(() => {
    if (!enabled) return;

    const handleBeforeUnload = () => {
      if (hasChanges && draftKey) {
        try {
          localStorage.setItem(draftKey, JSON.stringify(data));
        } catch {
          // Ignore localStorage errors
        }
      }
    };

    window.addEventListener('beforeunload', handleBeforeUnload);
    return () => window.removeEventListener('beforeunload', handleBeforeUnload);
  }, [enabled, hasChanges, data, draftKey]);

  return {
    isSaving,
    lastSaved,
    hasChanges,
    saveNow: save,
    clearDraft,
    error,
  };
}

/**
 * Load a saved draft from localStorage
 */
export function loadDraft<T>(draftKey: string): T | null {
  if (typeof window === 'undefined') return null;

  try {
    const stored = localStorage.getItem(draftKey);
    if (stored) {
      return JSON.parse(stored) as T;
    }
  } catch {
    // Ignore parse errors
  }

  return null;
}