From 2dceb72f60389f2b7ebcfd591b1586fc07d16a8c Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Sun, 10 May 2026 14:02:57 +0200 Subject: [PATCH] fix(client): cv editor - autosave loop, biome cleanup --- .../cv-templates/components/EditSidebar.tsx | 2 +- .../cv-templates/hooks/useAutosave.ts | 85 ++++++++++--------- .../cv-templates/hooks/useCVEditorData.ts | 5 +- 3 files changed, 49 insertions(+), 43 deletions(-) diff --git a/apps/client/src/features/cv-templates/components/EditSidebar.tsx b/apps/client/src/features/cv-templates/components/EditSidebar.tsx index 831c38b..79ecdfd 100644 --- a/apps/client/src/features/cv-templates/components/EditSidebar.tsx +++ b/apps/client/src/features/cv-templates/components/EditSidebar.tsx @@ -1,4 +1,4 @@ -import { Select, TextInput, Textarea } from "@cv/ui"; +import { Select, Textarea, TextInput } from "@cv/ui"; import type { ReactNode } from "react"; import type { SaveStatus } from "../hooks/useAutosave"; import type { CVEditorPatch, CVEditorState } from "../hooks/useCVEditor"; diff --git a/apps/client/src/features/cv-templates/hooks/useAutosave.ts b/apps/client/src/features/cv-templates/hooks/useAutosave.ts index e70dfdf..27959e5 100644 --- a/apps/client/src/features/cv-templates/hooks/useAutosave.ts +++ b/apps/client/src/features/cv-templates/hooks/useAutosave.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; export type SaveStatus = "idle" | "pending" | "saving" | "saved" | "error"; @@ -10,7 +10,7 @@ interface UseAutosaveOptions { isEqual?: (a: T, b: T) => boolean; } -const defaultIsEqual = (a: T, b: T): boolean => +const defaultIsEqual = (a: T, b: T): boolean => JSON.stringify(a) === JSON.stringify(b); /** @@ -24,11 +24,11 @@ const defaultIsEqual = (a: T, b: T): boolean => * scheduling time, so rapid edits collapse into a single save with the * latest content. */ -export const useAutosave = ({ +export const useAutosave = ({ value, initialValue, onSave, - delayMs = 600, + delayMs = 1200, isEqual = defaultIsEqual, }: UseAutosaveOptions): { status: SaveStatus; error: string | null } => { const [status, setStatus] = useState("idle"); @@ -36,48 +36,57 @@ export const useAutosave = ({ const lastSavedRef = useRef(initialValue); const valueRef = useRef(value); - const timeoutRef = useRef(null); + const onSaveRef = useRef(onSave); + const isEqualRef = useRef(isEqual); + const debounceRef = useRef(null); const savedTimerRef = useRef(null); valueRef.current = value; + onSaveRef.current = onSave; + isEqualRef.current = isEqual; - useEffect(() => { - if (isEqual(value, lastSavedRef.current)) { - return; + const clearDebounce = useCallback(() => { + if (debounceRef.current !== null) { + window.clearTimeout(debounceRef.current); + debounceRef.current = null; } + }, []); - setStatus("pending"); - - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current); - } - - timeoutRef.current = window.setTimeout(async () => { - const snapshot = valueRef.current; - setStatus("saving"); - setError(null); - try { - await onSave(snapshot); - lastSavedRef.current = snapshot; - setStatus("saved"); - if (savedTimerRef.current !== null) { - window.clearTimeout(savedTimerRef.current); - } - savedTimerRef.current = window.setTimeout(() => { - setStatus((current) => (current === "saved" ? "idle" : current)); - }, 2000); - } catch (err) { - setError(err instanceof Error ? err.message : "Save failed"); - setStatus("error"); + // Stable: reads the latest value/onSave/isEqual via refs. Bypasses the + // stale-closure trap where `onSave` changes identity mid-flight (every + // successful save invalidates queries, which refetch and cascade new + // `useMemo`/`useCallback` refs). Re-running the effect on those churn + // refs would schedule a redundant save against the previous snapshot + // before `lastSavedRef` has caught up. + const flush = useCallback(async () => { + const snapshot = valueRef.current; + setStatus("saving"); + setError(null); + try { + await onSaveRef.current(snapshot); + lastSavedRef.current = snapshot; + setStatus("saved"); + if (savedTimerRef.current !== null) { + window.clearTimeout(savedTimerRef.current); } - }, delayMs); + savedTimerRef.current = window.setTimeout(() => { + setStatus((current) => (current === "saved" ? "idle" : current)); + }, 2000); + } catch (err) { + setError(err instanceof Error ? err.message : "Save failed"); + setStatus("error"); + } + }, []); - return () => { - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current); - } - }; - }, [value, onSave, delayMs, isEqual]); + useEffect(() => { + if (isEqualRef.current(value, lastSavedRef.current)) { + return; + } + setStatus("pending"); + clearDebounce(); + debounceRef.current = window.setTimeout(flush, delayMs); + return clearDebounce; + }, [value, delayMs, flush, clearDebounce]); useEffect( () => () => { diff --git a/apps/client/src/features/cv-templates/hooks/useCVEditorData.ts b/apps/client/src/features/cv-templates/hooks/useCVEditorData.ts index 415bb6a..9b0bebc 100644 --- a/apps/client/src/features/cv-templates/hooks/useCVEditorData.ts +++ b/apps/client/src/features/cv-templates/hooks/useCVEditorData.ts @@ -13,10 +13,7 @@ import { export const useCVEditorData = (cvId: string) => { const cv = useCvQuery({ id: cvId }, { enabled: !!cvId }); const profileId = cv.data?.cv?.profileId ?? ""; - const profile = useProfileQuery( - { id: profileId }, - { enabled: !!profileId }, - ); + const profile = useProfileQuery({ id: profileId }, { enabled: !!profileId }); const render = useRenderCvQuery({ cvId }, { enabled: !!cvId }); const templates = useCvTemplatesQuery({ first: 50 }); -- 2.51.2