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/e2e/tests/admin-fixes.spec.ts b/e2e/tests/admin-fixes.spec.ts index 2b422032d..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 triggers after editing content (useMemo/useRef optimizations)", 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); @@ -335,7 +332,14 @@ 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, + }); + + // 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/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 f36710691..2c2762cb8 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 () => { @@ -508,7 +525,12 @@ export function ContentEditor({
{/* Autosave indicator */} {!isNew && onAutosave && ( -
+
{isAutosaving ? ( <> diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 011b65691..ff29a42d9 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,46 @@ 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; + const draftRevisionId = savedItem.draftRevisionId; + + if (draftRevisionId) { + queryClient.setQueryData(["revision", draftRevisionId], (existing) => { + const nextData: Record = { + ...existing?.data, + ...payload.data, + }; + + if (payload.slug !== undefined) { + nextData._slug = payload.slug; + } + + return { + id: 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,16 +700,19 @@ 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()); - // Invalidate content and draft revision so stale cached data - // doesn't overwrite the form via the sync effect - void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); - if (rawItem?.draftRevisionId) { - void queryClient.invalidateQueries({ - queryKey: ["revision", rawItem.draftRevisionId], - }); - } + // Keep the cache fresh without refetching older server state back into the form + // while the user is still typing. }, onError: (err) => { toastManager.add({ diff --git a/packages/admin/tests/components/ContentEditor.test.tsx b/packages/admin/tests/components/ContentEditor.test.tsx index 6f2a4b07d..c6d0f9677 100644 --- a/packages/admin/tests/components/ContentEditor.test.tsx +++ b/packages/admin/tests/components/ContentEditor.test.tsx @@ -296,6 +296,53 @@ describe("ContentEditor", () => { const savedBtn = screen.getByRole("button", { name: "Saved" }); await expect.element(savedBtn).toBeDisabled(); }); + + it("keeps edited values after autosave completes without queuing another 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(); + 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 { + vi.useRealTimers(); + } + }); }); describe("delete", () => { diff --git a/packages/admin/tests/router.test.tsx b/packages/admin/tests/router.test.tsx index 7197b6a3f..f24486a2e 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" } }); + }} + > + +
+ +
), })); @@ -257,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 }[] = []; @@ -272,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; @@ -281,3 +305,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: "Trigger Draft Sync" }).click(); + + await waitFor(() => { + expect(screen.getByTestId("mock-title").element().textContent).toBe("Autosaved Title"); + expect(screen.getByTestId("mock-slug").element().textContent).toBe("autosaved-title"); + }); + }); +});