From 8c47710aea6fae296c6c1594e3da6f1cb0183b8c Mon Sep 17 00:00:00 2001 From: ideepakchauhan7 Date: Mon, 6 Apr 2026 15:12:09 +0530 Subject: [PATCH 1/7] Fix autosave form reset bug Autosave was invalidating the query cache after completion, which triggered a refetch of content data from the server. This refetch updated the item prop, causing a useEffect in ContentEditor to reset form state to the server values, overwriting unsaved user changes. The fix removes the queryClient.invalidateQueries() call from the autosaveMutation onSuccess handler. The local form state remains the source of truth during editing, and manual saves still properly invalidate and refresh the cache. Fixes #295 --- .changeset/fix-autosave-form-reset.md | 5 +++++ packages/admin/src/router.tsx | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-autosave-form-reset.md diff --git a/.changeset/fix-autosave-form-reset.md b/.changeset/fix-autosave-form-reset.md new file mode 100644 index 000000000..cfccbcf91 --- /dev/null +++ b/.changeset/fix-autosave-form-reset.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Fixes autosave form reset bug. Autosave no longer invalidates the query cache, preventing form fields from reverting to server state after autosave completes. diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index e88a2dd31..0c6d62f41 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -622,8 +622,8 @@ 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 queries on autosave to prevent form reset + // The local form state is the source of truth during editing }, onError: (err) => { toastManager.add({ From 1c0760584ddb5eeb12279dc1bc5f6c1a6446f701 Mon Sep 17 00:00:00 2001 From: ideepakchauhan7 Date: Sat, 11 Apr 2026 23:15:46 +0530 Subject: [PATCH 2/7] Fix repeated autosave after successful autosave --- .../admin/src/components/ContentEditor.tsx | 19 ++++++++- .../tests/components/ContentEditor.test.tsx | 41 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index f36710691..db78597a0 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -257,6 +257,7 @@ export function ContentEditor({ })) ?? [], }), ); + const pendingAutosaveStateRef = React.useRef(null); // 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 @@ -282,6 +283,7 @@ export function ContentEditor({ })) ?? [], }), ); + pendingAutosaveStateRef.current = null; } }, [item?.updatedAt, itemDataString, item?.slug, item?.status]); @@ -319,6 +321,15 @@ export function ContentEditor({ const slugRef = React.useRef(slug); slugRef.current = slug; + React.useEffect(() => { + if (!lastAutosaveAt || !pendingAutosaveStateRef.current) { + return; + } + + setLastSavedData(pendingAutosaveStateRef.current); + pendingAutosaveStateRef.current = null; + }, [lastAutosaveAt]); + React.useEffect(() => { // Don't autosave for new items (no ID yet) or if autosave isn't configured if (isNew || !onAutosave || !item?.id) { @@ -337,11 +348,17 @@ export function ContentEditor({ // Schedule autosave autosaveTimeoutRef.current = setTimeout(() => { - onAutosave({ + const payload = { data: formDataRef.current, slug: slugRef.current || undefined, bylines: activeBylines, + }; + pendingAutosaveStateRef.current = serializeEditorState({ + data: payload.data, + slug: payload.slug || "", + bylines: payload.bylines, }); + onAutosave(payload); }, AUTOSAVE_DELAY); return () => { diff --git a/packages/admin/tests/components/ContentEditor.test.tsx b/packages/admin/tests/components/ContentEditor.test.tsx index a86719f02..788603ff4 100644 --- a/packages/admin/tests/components/ContentEditor.test.tsx +++ b/packages/admin/tests/components/ContentEditor.test.tsx @@ -296,6 +296,47 @@ describe("ContentEditor", () => { const savedBtn = screen.getByRole("button", { name: "Saved" }); await expect.element(savedBtn).toBeDisabled(); }); + + it("does not queue another autosave after a successful autosave", async () => { + vi.useFakeTimers(); + + try { + const item = makeItem(); + const onAutosave = vi.fn(); + const props: ContentEditorProps = { + collection: "posts", + collectionLabel: "Post", + fields: defaultFields, + isNew: false, + item, + onSave: vi.fn(), + onAutosave, + isAutosaving: false, + lastAutosaveAt: null, + }; + + const screen = await render(); + const titleInput = screen.getByLabelText("Title"); + await titleInput.fill("Updated title"); + + await vi.advanceTimersByTimeAsync(2000); + expect(onAutosave).toHaveBeenCalledTimes(1); + + await screen.rerender(); + await screen.rerender( + , + ); + + await vi.advanceTimersByTimeAsync(2500); + expect(onAutosave).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); }); describe("delete", () => { From dc01feb20d2acf1499372c4c79fa5b926b6a8037 Mon Sep 17 00:00:00 2001 From: ideepakchauhan7 Date: Sun, 12 Apr 2026 00:15:53 +0530 Subject: [PATCH 3/7] Stabilize autosave status assertions --- e2e/tests/admin-fixes.spec.ts | 4 +++- e2e/tests/revisions.spec.ts | 4 +++- packages/admin/src/components/ContentEditor.tsx | 7 ++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/e2e/tests/admin-fixes.spec.ts b/e2e/tests/admin-fixes.spec.ts index 2b422032d..5e6ebb5d4 100644 --- a/e2e/tests/admin-fixes.spec.ts +++ b/e2e/tests/admin-fixes.spec.ts @@ -335,7 +335,9 @@ test.describe("Autosave after perf optimizations", () => { expect(response.status()).toBe(200); // The autosave indicator should show "Saved" - await expect(page.locator("text=Saved")).toBeVisible({ timeout: 5000 }); + await expect(page.getByRole("status", { name: "Autosave status" })).toContainText("Saved", { + timeout: 5000, + }); }); test("multiple rapid edits result in single autosave (debounce still works)", async ({ diff --git a/e2e/tests/revisions.spec.ts b/e2e/tests/revisions.spec.ts index a6a14bfc9..867a19857 100644 --- a/e2e/tests/revisions.spec.ts +++ b/e2e/tests/revisions.spec.ts @@ -163,7 +163,9 @@ test.describe("Revisions", () => { expect(response.status()).toBe(200); // Wait for autosave indicator - await expect(page.locator("text=Saved")).toBeVisible({ timeout: 5000 }); + await expect(page.getByRole("status", { name: "Autosave status" })).toContainText("Saved", { + timeout: 5000, + }); // Now publish to create a new live revision const publishButton = page.getByRole("button", { name: "Publish" }); diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index db78597a0..2c2762cb8 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -525,7 +525,12 @@ export function ContentEditor({
{/* Autosave indicator */} {!isNew && onAutosave && ( -
+
{isAutosaving ? ( <> From 8f3c19613baef71ec89ce5c18bc495bff9dbac68 Mon Sep 17 00:00:00 2001 From: ideepakchauhan7 Date: Mon, 13 Apr 2026 00:00:01 +0530 Subject: [PATCH 4/7] Patch autosave cache after autosave --- e2e/tests/admin-fixes.spec.ts | 7 +- packages/admin/src/router.tsx | 55 +++++++++- packages/admin/tests/router.test.tsx | 155 +++++++++++++++++++++++++-- 3 files changed, 202 insertions(+), 15 deletions(-) diff --git a/e2e/tests/admin-fixes.spec.ts b/e2e/tests/admin-fixes.spec.ts index 5e6ebb5d4..bb8d69a43 100644 --- a/e2e/tests/admin-fixes.spec.ts +++ b/e2e/tests/admin-fixes.spec.ts @@ -305,7 +305,7 @@ test.describe("Autosave after perf optimizations", () => { }).catch(() => {}); }); - test("autosave triggers after editing content (useMemo/useRef optimizations)", async ({ + test("autosave keeps edited field values after save completes", async ({ admin, page, }) => { @@ -338,6 +338,11 @@ test.describe("Autosave after perf optimizations", () => { await expect(page.getByRole("status", { name: "Autosave status" })).toContainText("Saved", { timeout: 5000, }); + + // Regression: autosave should not snap the input back to older cached server state. + await expect(titleInput).toHaveValue("Autosave Perf Test Edit"); + await page.waitForTimeout(500); + await expect(titleInput).toHaveValue("Autosave Perf Test Edit"); }); test("multiple rapid edits result in single autosave (debounce still works)", async ({ diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 24a108b14..9c5437f9e 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -99,6 +99,8 @@ import { type CreateFieldInput, type BylineCreditInput, type ContentSeoInput, + type ContentItem, + type Revision, } from "./lib/api"; import { fetchComments, @@ -118,6 +120,45 @@ interface RouterContext { queryClient: QueryClient; } +function patchAutosaveQueries( + queryClient: QueryClient, + params: { + collection: string; + id: string; + savedItem: ContentItem; + payload: { + data?: Record; + slug?: string; + }; + }, +) { + const { collection, id, savedItem, payload } = params; + + if (savedItem.draftRevisionId) { + queryClient.setQueryData(["revision", savedItem.draftRevisionId], (existing) => { + const nextData: Record = { + ...existing?.data, + ...payload.data, + }; + + if (payload.slug !== undefined) { + nextData._slug = payload.slug; + } + + return { + id: savedItem.draftRevisionId, + collection, + entryId: id, + data: nextData, + authorId: existing?.authorId ?? savedItem.authorId, + createdAt: existing?.createdAt ?? savedItem.updatedAt, + }; + }); + } + + queryClient.setQueryData(["content", collection, id], savedItem); +} + // Create a base root route without Shell for setup const baseRootRoute = createRootRouteWithContext()({ component: () => , @@ -658,10 +699,18 @@ function ContentEditPage() { slug?: string; bylines?: BylineCreditInput[]; }) => updateContent(collection, id, { ...data, skipRevision: true }), - onSuccess: () => { + onSuccess: (savedItem, variables) => { + patchAutosaveQueries(queryClient, { + collection, + id, + savedItem, + payload: { + data: variables.data, + slug: variables.slug, + }, + }); setLastAutosaveAt(new Date()); - // Keep the editor's local state as the source of truth during autosave. - // Invalidating here can refetch slightly older server data and reset the form + // Keep the cache fresh without refetching older server state back into the form // while the user is still typing. }, onError: (err) => { diff --git a/packages/admin/tests/router.test.tsx b/packages/admin/tests/router.test.tsx index 7197b6a3f..d432a5eb5 100644 --- a/packages/admin/tests/router.test.tsx +++ b/packages/admin/tests/router.test.tsx @@ -28,7 +28,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { AdminManifest } from "../src/lib/api"; import { createAdminRouter } from "../src/router"; import { render } from "./utils/render.tsx"; -import { createTestQueryClient, createMockFetch } from "./utils/test-helpers"; +import { createTestQueryClient, createMockFetch, waitFor } from "./utils/test-helpers"; // --------------------------------------------------------------------------- // Component mocks – keep layout plumbing out of these tests @@ -43,16 +43,38 @@ vi.mock("../src/components/AdminCommandPalette", () => ({ })); vi.mock("../src/components/ContentEditor", () => ({ - ContentEditor: ({ onSave }: { onSave: (payload: { data: Record }) => void }) => ( -
{ - e.preventDefault(); - onSave({ data: { title: "Test Post" } }); - }} - > - -
+ ContentEditor: ({ + item, + onSave, + onAutosave, + }: { + item?: { data?: { title?: string }; slug?: string | null }; + onSave?: (payload: { data: Record }) => void; + onAutosave?: (payload: { data: Record; slug?: string }) => void; + }) => ( +
+
{item?.data?.title ?? ""}
+
{item?.slug ?? ""}
+
{ + e.preventDefault(); + onSave?.({ data: { title: "Test Post" } }); + }} + > + +
+ +
), })); @@ -281,3 +303,114 @@ describe("ContentNewPage – locale passed to createContent", () => { expect(requests[0]!.body).toMatchObject({ locale: "de" }); }); }); + +// --------------------------------------------------------------------------- +// Tests: ContentEditPage – autosave cache stays in sync +// --------------------------------------------------------------------------- + +describe("ContentEditPage – autosave cache patching", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = createMockFetch(); + + const manifestWithRevisions: AdminManifest = { + ...MANIFEST, + i18n: undefined, + collections: { + posts: { + ...MANIFEST.collections.posts, + supports: ["drafts", "revisions"], + }, + }, + }; + + mockFetch + .on("GET", "/_emdash/api/manifest", { data: manifestWithRevisions }) + .on("GET", "/_emdash/api/auth/me", { + data: { id: "user_01", role: 30 }, + }) + .on("GET", "/_emdash/api/bylines", { data: { items: [] } }) + .on("GET", "/_emdash/api/content/posts/post_1", { + data: { + item: { + id: "post_1", + type: "posts", + slug: "published-slug", + status: "draft", + locale: "en", + translationGroup: null, + data: { title: "Published Title" }, + authorId: null, + primaryBylineId: null, + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-01T00:00:00Z", + publishedAt: "2025-01-01T00:00:00Z", + scheduledAt: null, + liveRevisionId: "rev_live", + draftRevisionId: "rev_draft", + }, + }, + }) + .on("GET", "/_emdash/api/revisions/rev_draft", { + data: { + item: { + id: "rev_draft", + collection: "posts", + entryId: "post_1", + data: { title: "Draft Title", _slug: "draft-slug" }, + authorId: null, + createdAt: "2025-01-01T00:00:00Z", + }, + }, + }) + .on("PUT", "/_emdash/api/content/posts/post_1", { + data: { + item: { + id: "post_1", + type: "posts", + slug: "published-slug", + status: "draft", + locale: "en", + translationGroup: null, + data: { title: "Published Title" }, + authorId: null, + primaryBylineId: null, + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-02T00:00:00Z", + publishedAt: "2025-01-01T00:00:00Z", + scheduledAt: null, + liveRevisionId: "rev_live", + draftRevisionId: "rev_draft", + }, + }, + }); + }); + + afterEach(() => { + mockFetch.restore(); + }); + + it("keeps the edited draft title and slug after autosave completes", async () => { + const { router, TestApp } = buildRouter(); + + await router.navigate({ + to: "/content/$collection/$id", + params: { collection: "posts", id: "post_1" }, + }); + + const screen = await render(); + + await waitFor(() => { + expect(screen.getByTestId("mock-title").element().textContent).toBe("Draft Title"); + expect(screen.getByTestId("mock-slug").element().textContent).toBe("draft-slug"); + }); + + await screen.getByRole("button", { name: "Autosave" }).click(); + + await waitFor(() => { + expect(screen.getByTestId("mock-title").element().textContent).toBe("Autosaved Title"); + expect(screen.getByTestId("mock-slug").element().textContent).toBe("autosaved-title"); + }); + }); +}); From c82e60b26505a02c03ac652eabcea71eb3516967 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Sun, 12 Apr 2026 18:31:46 +0000 Subject: [PATCH 5/7] style: format --- e2e/tests/admin-fixes.spec.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/e2e/tests/admin-fixes.spec.ts b/e2e/tests/admin-fixes.spec.ts index bb8d69a43..93d1f51c0 100644 --- a/e2e/tests/admin-fixes.spec.ts +++ b/e2e/tests/admin-fixes.spec.ts @@ -305,10 +305,7 @@ test.describe("Autosave after perf optimizations", () => { }).catch(() => {}); }); - test("autosave keeps edited field values after save completes", async ({ - admin, - page, - }) => { + test("autosave keeps edited field values after save completes", async ({ admin, page }) => { const contentUrl = `/_emdash/api/content/${collectionSlug}/${postId}`; await admin.goToEditContent(collectionSlug, postId); From 14f95fa711148c2f23da66d3d12186b14cb5aa54 Mon Sep 17 00:00:00 2001 From: ideepakchauhan7 Date: Mon, 13 Apr 2026 00:06:28 +0530 Subject: [PATCH 6/7] Add autosave regression coverage --- packages/admin/tests/components/ContentEditor.test.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/admin/tests/components/ContentEditor.test.tsx b/packages/admin/tests/components/ContentEditor.test.tsx index da42614c1..c6d0f9677 100644 --- a/packages/admin/tests/components/ContentEditor.test.tsx +++ b/packages/admin/tests/components/ContentEditor.test.tsx @@ -297,7 +297,7 @@ describe("ContentEditor", () => { await expect.element(savedBtn).toBeDisabled(); }); - it("does not queue another autosave after a successful autosave", async () => { + it("keeps edited values after autosave completes without queuing another autosave", async () => { vi.useFakeTimers(); try { @@ -323,14 +323,20 @@ describe("ContentEditor", () => { expect(onAutosave).toHaveBeenCalledTimes(1); await screen.rerender(); + const autosavedItem = makeItem({ + updatedAt: "2026-04-12T18:38:00Z", + data: { title: "Updated title", body: "Some content" }, + }); await screen.rerender( , ); + await expect.element(screen.getByLabelText("Title")).toHaveValue("Updated title"); await vi.advanceTimersByTimeAsync(2500); expect(onAutosave).toHaveBeenCalledTimes(1); } finally { From c18e25d93de6eaaef029ee88e1d5a857c5560817 Mon Sep 17 00:00:00 2001 From: ideepakchauhan7 Date: Mon, 13 Apr 2026 00:48:39 +0530 Subject: [PATCH 7/7] Fix autosave CI follow-ups --- packages/admin/src/router.tsx | 7 ++++--- packages/admin/tests/router.test.tsx | 10 ++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 9c5437f9e..ff29a42d9 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -133,9 +133,10 @@ function patchAutosaveQueries( }, ) { const { collection, id, savedItem, payload } = params; + const draftRevisionId = savedItem.draftRevisionId; - if (savedItem.draftRevisionId) { - queryClient.setQueryData(["revision", savedItem.draftRevisionId], (existing) => { + if (draftRevisionId) { + queryClient.setQueryData(["revision", draftRevisionId], (existing) => { const nextData: Record = { ...existing?.data, ...payload.data, @@ -146,7 +147,7 @@ function patchAutosaveQueries( } return { - id: savedItem.draftRevisionId, + id: draftRevisionId, collection, entryId: id, data: nextData, diff --git a/packages/admin/tests/router.test.tsx b/packages/admin/tests/router.test.tsx index d432a5eb5..f24486a2e 100644 --- a/packages/admin/tests/router.test.tsx +++ b/packages/admin/tests/router.test.tsx @@ -72,7 +72,7 @@ vi.mock("../src/components/ContentEditor", () => ({ }) } > - Autosave + Trigger Draft Sync
), @@ -279,7 +279,9 @@ describe("ContentNewPage – locale passed to createContent", () => { const screen = await render(); // Wait for the editor to appear (manifest must have loaded) - await expect.element(screen.getByRole("button", { name: "Save" })).toBeInTheDocument(); + await expect + .element(screen.getByRole("button", { name: "Save", exact: true })) + .toBeInTheDocument(); // Capture outgoing requests const requests: { url: string; body: unknown }[] = []; @@ -294,7 +296,7 @@ describe("ContentNewPage – locale passed to createContent", () => { return origFetch(input, init); }; - await screen.getByRole("button", { name: "Save" }).click(); + await screen.getByRole("button", { name: "Save", exact: true }).click(); globalThis.fetch = origFetch; @@ -406,7 +408,7 @@ describe("ContentEditPage – autosave cache patching", () => { expect(screen.getByTestId("mock-slug").element().textContent).toBe("draft-slug"); }); - await screen.getByRole("button", { name: "Autosave" }).click(); + await screen.getByRole("button", { name: "Trigger Draft Sync" }).click(); await waitFor(() => { expect(screen.getByTestId("mock-title").element().textContent).toBe("Autosaved Title");