Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8c47710
Fix autosave form reset bug
ideepakchauhan7 Apr 6, 2026
436be05
Merge branch 'main' into fix-autosave-reset
ideepakchauhan7 Apr 6, 2026
474c004
Merge branch 'main' into fix-autosave-reset
ideepakchauhan7 Apr 6, 2026
99f7c5c
Merge branch 'main' into fix-autosave-reset
ideepakchauhan7 Apr 7, 2026
bb26f11
Merge branch 'main' into fix-autosave-reset
ideepakchauhan7 Apr 7, 2026
d95c74a
Merge upstream/main into fix-autosave-reset
ideepakchauhan7 Apr 11, 2026
81916f0
Merge remote-tracking branch 'origin/fix-autosave-reset' into fix-aut…
ideepakchauhan7 Apr 11, 2026
7ac9904
Merge branch 'main' into fix-autosave-reset
ideepakchauhan7 Apr 11, 2026
1c07605
Fix repeated autosave after successful autosave
ideepakchauhan7 Apr 11, 2026
49bee11
Merge remote-tracking branch 'origin/fix-autosave-reset' into fix-aut…
ideepakchauhan7 Apr 11, 2026
dc01feb
Stabilize autosave status assertions
ideepakchauhan7 Apr 11, 2026
eb23ff4
Merge branch 'main' into fix-autosave-reset
ideepakchauhan7 Apr 11, 2026
6483708
Merge branch 'main' into fix-autosave-reset
ideepakchauhan7 Apr 12, 2026
8f3c196
Patch autosave cache after autosave
ideepakchauhan7 Apr 12, 2026
c82e60b
style: format
emdashbot[bot] Apr 12, 2026
14f95fa
Add autosave regression coverage
ideepakchauhan7 Apr 12, 2026
714bef2
Merge branch 'main' into fix-autosave-reset
ideepakchauhan7 Apr 12, 2026
4a5011b
Merge branch 'main' into fix-autosave-reset
ideepakchauhan7 Apr 12, 2026
c18e25d
Fix autosave CI follow-ups
ideepakchauhan7 Apr 12, 2026
5cb7360
Merge branch 'main' into fix-autosave-reset
ideepakchauhan7 Apr 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-autosave-form-reset.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 9 additions & 5 deletions e2e/tests/admin-fixes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 ({
Expand Down
4 changes: 3 additions & 1 deletion e2e/tests/revisions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
26 changes: 24 additions & 2 deletions packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ export function ContentEditor({
})) ?? [],
}),
);
const pendingAutosaveStateRef = React.useRef<string | null>(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
Expand All @@ -282,6 +283,7 @@ export function ContentEditor({
})) ?? [],
}),
);
pendingAutosaveStateRef.current = null;
}
}, [item?.updatedAt, itemDataString, item?.slug, item?.status]);

Expand Down Expand Up @@ -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) {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -508,7 +525,12 @@ export function ContentEditor({
<div className="flex items-center space-x-2">
{/* Autosave indicator */}
{!isNew && onAutosave && (
<div className="flex items-center text-xs text-kumo-subtle">
<div
className="flex items-center text-xs text-kumo-subtle"
role="status"
aria-label="Autosave status"
aria-live="polite"
>
{isAutosaving ? (
<>
<Loader size="sm" />
Expand Down
63 changes: 54 additions & 9 deletions packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ import {
type CreateFieldInput,
type BylineCreditInput,
type ContentSeoInput,
type ContentItem,
type Revision,
} from "./lib/api";
import {
fetchComments,
Expand All @@ -118,6 +120,46 @@ interface RouterContext {
queryClient: QueryClient;
}

function patchAutosaveQueries(
queryClient: QueryClient,
params: {
collection: string;
id: string;
savedItem: ContentItem;
payload: {
data?: Record<string, unknown>;
slug?: string;
};
},
) {
const { collection, id, savedItem, payload } = params;
const draftRevisionId = savedItem.draftRevisionId;

if (draftRevisionId) {
queryClient.setQueryData<Revision>(["revision", draftRevisionId], (existing) => {
const nextData: Record<string, unknown> = {
...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<ContentItem>(["content", collection, id], savedItem);
}

// Create a base root route without Shell for setup
const baseRootRoute = createRootRouteWithContext<RouterContext>()({
component: () => <Outlet />,
Expand Down Expand Up @@ -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({
Expand Down
47 changes: 47 additions & 0 deletions packages/admin/tests/components/ContentEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ContentEditor {...props} />);
const titleInput = screen.getByLabelText("Title");
await titleInput.fill("Updated title");

await vi.advanceTimersByTimeAsync(2000);
expect(onAutosave).toHaveBeenCalledTimes(1);

await screen.rerender(<ContentEditor {...props} isAutosaving={true} />);
const autosavedItem = makeItem({
updatedAt: "2026-04-12T18:38:00Z",
data: { title: "Updated title", body: "Some content" },
});
await screen.rerender(
<ContentEditor
{...props}
item={autosavedItem}
isAutosaving={false}
lastAutosaveAt={new Date("2026-04-12T18:38:00Z")}
/>,
);

await expect.element(screen.getByLabelText("Title")).toHaveValue("Updated title");
await vi.advanceTimersByTimeAsync(2500);
expect(onAutosave).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});

describe("delete", () => {
Expand Down
Loading
Loading