From 7582b0641d56e3595b77a3e8b3ccd07e74b326c4 Mon Sep 17 00:00:00 2001 From: Tasdemir Date: Sun, 5 Apr 2026 17:57:02 +0300 Subject: [PATCH] fix(admin): prevent autosave from overwriting in-progress edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When editing content fields (title, slug, excerpt, etc.) in the admin editor, user input was being silently reverted to server values. This made normal-speed editing nearly impossible — only rapid copy-paste would survive. Root cause: a circular data refresh loop between autosave and the form state synchronization effect: 1. User edits a field → local form state updates → isDirty becomes true 2. After 2s idle, autosave fires → sends current form data to server 3. Autosave onSuccess called invalidateQueries() for the content query 4. React Query refetched the content from the server 5. The form-sync useEffect depended on item.updatedAt, which changed on every server write (even autosave) 6. The effect unconditionally reset all form state (formData, slug, status, bylines) to the refetched server values 7. Any edits made between step 2 and step 6 were silently lost This was particularly noticeable on fields like slug and excerpt where users type at normal speed, but also affected the title and rich text body fields. Fix (two changes): 1. router.tsx — Remove invalidateQueries() from the autosave mutation's onSuccess callback. Autosave already sent the latest data to the server; there is no need to immediately refetch it back. The query cache refreshes naturally on manual save, page navigation, or window refocus (React Query default behavior). 2. ContentEditor.tsx — Remove item.updatedAt from the form-sync useEffect's dependency array. The timestamp changes on every server write but carries no meaningful data change. The effect now only fires when actual content (itemDataString), slug, or status change, which correctly covers explicit actions like "Discard Draft" and "Restore Revision" while ignoring timestamp-only updates from autosave or refetch. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/admin/src/components/ContentEditor.tsx | 9 ++++++++- packages/admin/src/router.tsx | 6 ++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index d55d82e7..d704aeee 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -251,6 +251,13 @@ export function ContentEditor({ // Update form and last saved state when item changes (e.g., after save or restore) // Stringify the data for comparison since objects are compared by reference + // + // NOTE: item?.updatedAt is intentionally excluded from the dependency array. + // Including it caused a circular reset loop: autosave → server updates updatedAt → + // query refetch → this effect fires → form state resets to server values → + // user's in-progress edits are lost. By depending only on actual data/slug/status + // changes, the form only resets when content meaningfully changes (e.g., after + // discard draft or restore revision), not on every timestamp bump. const itemDataString = React.useMemo(() => (item ? JSON.stringify(item.data) : ""), [item?.data]); React.useEffect(() => { if (item) { @@ -274,7 +281,7 @@ export function ContentEditor({ }), ); } - }, [item?.updatedAt, itemDataString, item?.slug, item?.status]); + }, [itemDataString, item?.slug, item?.status]); const activeBylines = isNew ? (selectedBylines ?? []) : internalBylines; diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index c8ffb58a..f004fd9e 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -621,8 +621,10 @@ function ContentEditPage() { }) => updateContent(collection, id, { ...data, skipRevision: true }), onSuccess: () => { setLastAutosaveAt(new Date()); - // Silently update the cache without full invalidation - void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); + // Don't invalidate the query here — a refetch would trigger the + // form-reset useEffect in ContentEditor and overwrite any edits + // the user made between the autosave request and the refetch response. + // The cache refreshes naturally on manual save, navigation, or window refocus. }, onError: (err) => { toastManager.add({