Skip to content

Commit cba1135

Browse files
fix(admin): prevent stale autosave payload from overwriting live edits in repeater sub-fields (#2878) (#3107)
EmDash-Run: e39d70d7-e317-48ec-8747-078332f3483b Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
1 parent 4ec13c3 commit cba1135

3 files changed

Lines changed: 77 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@emdash-cms/admin": patch
3+
---
4+
5+
Fixes autosave responses from overwriting live edits when they resolve after further typing. Previously, an older autosave payload could replace edits made in repeater sub-fields and other form controls while the request was in flight.

packages/admin/src/components/ContentEditor.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -431,10 +431,21 @@ export function ContentEditor({
431431
() => (item ? JSON.stringify(item.bylines ?? []) : ""),
432432
[item?.bylines],
433433
);
434+
const autosaveCompletionTokenRef = React.useRef(autosaveCompletionToken ?? 0);
434435
React.useEffect(() => {
435436
if (item) {
436437
const nextBylines = resolveEditorBylines(item).explicitCredits;
437-
if (!isPublishingRef.current) {
438+
const previousAutosaveToken = autosaveCompletionTokenRef.current;
439+
const autosaveJustCompleted =
440+
(autosaveCompletionToken ?? 0) > 0 &&
441+
(autosaveCompletionToken ?? 0) !== previousAutosaveToken;
442+
autosaveCompletionTokenRef.current = autosaveCompletionToken ?? 0;
443+
444+
// When an autosave resolves, the server payload is a snapshot from the
445+
// moment the request was sent. Writing it back into formData would
446+
// clobber edits made while the request was in flight, including nested
447+
// repeater sub-fields. The pending autosave effect handles lastSavedData.
448+
if (!isPublishingRef.current && !autosaveJustCompleted) {
438449
setFormData(item.data);
439450
setSlug(item.slug || "");
440451
setSlugTouched(!!item.slug);
@@ -449,10 +460,19 @@ export function ContentEditor({
449460
bylines: nextBylines,
450461
}),
451462
);
452-
pendingAutosaveStateRef.current = null;
453-
setRejectedAutosaveState(null);
463+
if (!autosaveJustCompleted) {
464+
pendingAutosaveStateRef.current = null;
465+
setRejectedAutosaveState(null);
466+
}
454467
}
455-
}, [item?.updatedAt, itemDataString, itemBylinesString, item?.slug, item?.status]);
468+
}, [
469+
item?.updatedAt,
470+
itemDataString,
471+
itemBylinesString,
472+
item?.slug,
473+
item?.status,
474+
autosaveCompletionToken,
475+
]);
456476

457477
const activeBylines = isNew ? (selectedBylines ?? []) : internalBylines;
458478
const unsupportedPortableTextMarks = React.useMemo(() => {

packages/admin/tests/components/ContentEditor.test.tsx

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2496,15 +2496,58 @@ describe("ContentEditor", () => {
24962496

24972497
await expect.element(screen.getByRole("button", { name: "Save anyway" })).not.toBeDisabled();
24982498
});
2499+
});
24992500

2500-
it("leaves the rich text editor writable when the entry is not locked", async () => {
2501-
await renderEditor({
2501+
it("leaves the rich text editor writable when the entry is not locked", async () => {
2502+
await renderEditor({
2503+
isNew: false,
2504+
item: makeItem(),
2505+
fields: { content: { kind: "portableText", label: "Content" } },
2506+
});
2507+
2508+
expect(portableTextProps.current?.editable).toBe(true);
2509+
});
2510+
2511+
describe("autosave race with repeater sub-field", () => {
2512+
it("does not overwrite a sub-field input with a stale autosave payload", async () => {
2513+
const fields: Record<string, FieldDescriptor> = {
2514+
gallery: {
2515+
kind: "repeater",
2516+
label: "Gallery",
2517+
validation: {
2518+
subFields: [{ slug: "caption", type: "string", label: "Caption" }],
2519+
},
2520+
},
2521+
};
2522+
2523+
const screen = await renderEditor({
25022524
isNew: false,
2503-
item: makeItem(),
2504-
fields: { content: { kind: "portableText", label: "Content" } },
2525+
item: makeItem({ data: { gallery: [] } }),
2526+
fields,
2527+
onAutosave: vi.fn(),
2528+
supportsDrafts: true,
25052529
});
25062530

2507-
expect(portableTextProps.current?.editable).toBe(true);
2531+
await screen.getByRole("button", { name: "Add First Item", exact: true }).click();
2532+
const caption = screen.getByRole("textbox", { name: "Caption" });
2533+
await expect.element(caption).toBeVisible();
2534+
await caption.fill("Mobile view of the dashboard");
2535+
2536+
await screen.rerender(
2537+
<ContentEditor
2538+
collection="posts"
2539+
collectionLabel="Post"
2540+
fields={fields}
2541+
isNew={false}
2542+
item={makeItem({ data: { gallery: [{ caption: "Mobile view" }] } })}
2543+
onSave={vi.fn()}
2544+
onAutosave={vi.fn()}
2545+
supportsDrafts={true}
2546+
autosaveCompletionToken={1}
2547+
/>,
2548+
);
2549+
2550+
await expect.element(caption).toHaveValue("Mobile view of the dashboard");
25082551
});
25092552
});
25102553
});

0 commit comments

Comments
 (0)