Skip to content

Commit 1a71c9e

Browse files
fix(admin): preserve revision tokens after unpublish/discard/restore (#3089) (#3092)
EmDash-Run: c935372c-f26a-4aca-9d95-801ea67056cd Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
1 parent 107c3cc commit 1a71c9e

9 files changed

Lines changed: 681 additions & 23 deletions

File tree

.changeset/unpublish-token-fix.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@emdash-cms/admin": patch
3+
"emdash": patch
4+
---
5+
6+
Fix stale revision tokens after unpublish, discard, and revision restore.
7+
8+
The admin editor now reads the new `_rev` returned by unpublish, discard-draft, and revision-restore responses and advances its optimistic-concurrency token before the next save. Unpublish also flushes pending editor changes before sending the request, matching the ordering already used for publish, schedule, and publication-date changes, and it now catches the promise rejection when the action is blocked by invalid fields or a click while another publishing action is already in progress. This prevents subsequent autosaves or publish actions from being refused as a 409 conflict, stops unpublished posts from overwriting unsaved edits, and avoids unhandled promise rejections from the unpublish button.

packages/admin/src/components/ContentEditor.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,15 @@ export interface ContentEditorProps {
173173
slug?: string;
174174
bylines?: BylineCreditInput[];
175175
}) => void | Promise<void>;
176-
onUnpublish?: () => void;
176+
onUnpublish?: (payload?: {
177+
data: Record<string, unknown>;
178+
slug?: string;
179+
bylines?: BylineCreditInput[];
180+
}) => void | Promise<void>;
177181
/** Callback to discard draft changes (revert to published version) */
178182
onDiscardDraft?: () => void;
183+
/** Callback when a revision is restored from the sidebar. */
184+
onRevisionRestored?: (item: ContentItem) => void;
179185
/** Callback to schedule for future publishing */
180186
onSchedule?: (
181187
scheduledAt: string,
@@ -279,6 +285,7 @@ export function ContentEditor({
279285
onPublish,
280286
onUnpublish,
281287
onDiscardDraft,
288+
onRevisionRestored,
282289
onSchedule,
283290
onUnschedule,
284291
isScheduling,
@@ -741,6 +748,11 @@ export function ContentEditor({
741748
() => (onUnschedule ? runScheduleChange((payload) => onUnschedule(payload)) : undefined),
742749
[onUnschedule, runScheduleChange],
743750
);
751+
const handleUnpublish = React.useCallback(() => {
752+
if (!onUnpublish) return;
753+
const unpublish = onUnpublish;
754+
void Promise.resolve(runScheduleChange((payload) => unpublish(payload))).catch(() => undefined);
755+
}, [onUnpublish, runScheduleChange]);
744756
const handlePublishedAtChange = React.useCallback(
745757
(publishedAt: string) =>
746758
onPublishedAtChange
@@ -980,7 +992,7 @@ export function ContentEditor({
980992
isScheduling={isScheduling}
981993
isUnscheduling={isUnscheduling}
982994
onPublish={handlePublish}
983-
onUnpublish={onUnpublish}
995+
onUnpublish={handleUnpublish}
984996
onOpenSchedule={onSchedule ? handleOpenSchedule : undefined}
985997
onUnschedule={onUnschedule ? handleUnschedule : undefined}
986998
onMenuOpenChange={setPublishingMenuOpen}
@@ -1047,7 +1059,7 @@ export function ContentEditor({
10471059
isScheduling={isScheduling}
10481060
isUnscheduling={isUnscheduling}
10491061
onPublish={handlePublish}
1050-
onUnpublish={onUnpublish}
1062+
onUnpublish={handleUnpublish}
10511063
onOpenSchedule={onSchedule ? handleOpenSchedule : undefined}
10521064
onUnschedule={onUnschedule ? handleUnschedule : undefined}
10531065
onMenuOpenChange={setPublishingMenuOpen}
@@ -1159,7 +1171,7 @@ export function ContentEditor({
11591171
isLoadingPreview={isLoadingPreview}
11601172
onPreview={handlePreview}
11611173
onPublish={handlePublish}
1162-
onUnpublish={onUnpublish}
1174+
onUnpublish={handleUnpublish}
11631175
onOpenSchedule={onSchedule ? handleOpenSchedule : undefined}
11641176
onUnschedule={onUnschedule ? handleUnschedule : undefined}
11651177
onMenuOpenChange={setPublishingMenuOpen}
@@ -1192,6 +1204,7 @@ export function ContentEditor({
11921204
onPublishedAtChange={onPublishedAtChange ? handlePublishedAtChange : undefined}
11931205
isUpdatingPublishedAt={isUpdatingPublishedAt}
11941206
onDiscardDraft={onDiscardDraft}
1207+
onRevisionRestored={onRevisionRestored}
11951208
onDelete={onDelete}
11961209
isDeleting={isDeleting}
11971210
currentUser={currentUser}

packages/admin/src/components/ContentSettingsPanel.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,7 @@ export interface ContentSettingsPanelProps {
714714
onPublishedAtChange?: (publishedAt: string) => void | Promise<void>;
715715
isUpdatingPublishedAt?: boolean;
716716
onDiscardDraft?: () => void;
717+
onRevisionRestored?: (item: ContentItem) => void;
717718
onDelete?: () => void;
718719
isDeleting?: boolean;
719720
currentUser?: CurrentUserInfo;
@@ -766,6 +767,7 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({
766767
onPublishedAtChange,
767768
isUpdatingPublishedAt,
768769
onDiscardDraft,
770+
onRevisionRestored,
769771
onDelete,
770772
isDeleting,
771773
currentUser,
@@ -1171,7 +1173,12 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({
11711173
{!isNew && item && supportsRevisions && (
11721174
<SortableContentSettingsSection id="revisions" label={t`Revisions`} disclosure>
11731175
<div className="p-4">
1174-
<RevisionHistory collection={collection} entryId={item.id} reserveHeaderEnd />
1176+
<RevisionHistory
1177+
collection={collection}
1178+
entryId={item.id}
1179+
onRestored={onRevisionRestored}
1180+
reserveHeaderEnd
1181+
/>
11751182
</div>
11761183
</SortableContentSettingsSection>
11771184
)}

packages/admin/src/components/RevisionHistory.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ArrowCounterClockwise, CaretDown, Plus, Minus, PencilSimple } from "@ph
55
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
66
import * as React from "react";
77

8-
import { fetchRevisions, restoreRevision, type Revision } from "../lib/api";
8+
import { fetchRevisions, restoreRevision, type ContentItem, type Revision } from "../lib/api";
99
import { cn, formatRelativeTime, parseTimestamp } from "../lib/utils";
1010
import { ConfirmDialog } from "./ConfirmDialog";
1111

@@ -69,8 +69,8 @@ function formatDiffValue(value: unknown): string {
6969
interface RevisionHistoryProps {
7070
collection: string;
7171
entryId: string;
72-
/** Called when a revision is successfully restored */
73-
onRestored?: () => void;
72+
/** Called when a revision is successfully restored with the returned item. */
73+
onRestored?: (item: ContentItem) => void;
7474
/** Reserve the inline end of the disclosure header for an external control. */
7575
reserveHeaderEnd?: boolean;
7676
}
@@ -114,7 +114,7 @@ export function RevisionHistory({
114114

115115
const restoreMutation = useMutation({
116116
mutationFn: (revisionId: string) => restoreRevision(revisionId),
117-
onSuccess: () => {
117+
onSuccess: (restoredItem) => {
118118
// Invalidate content and revisions queries
119119
void queryClient.invalidateQueries({
120120
queryKey: ["content", collection, entryId],
@@ -124,7 +124,7 @@ export function RevisionHistory({
124124
});
125125
setSelectedRevision(null);
126126
setRestoreTarget(null);
127-
onRestored?.();
127+
onRestored?.(restoredItem);
128128
toastManager.add({
129129
title: t`Revision restored`,
130130
description: t`Content has been updated to the selected revision.`,

packages/admin/src/lib/api/content.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -512,19 +512,21 @@ export async function publishContent(
512512
export async function unpublishContent(
513513
collection: string,
514514
id: string,
515-
options?: { locale?: string },
515+
options?: { locale?: string; _rev?: string },
516516
): Promise<ContentItem> {
517517
const params = new URLSearchParams();
518518
if (options?.locale) params.set("locale", options.locale);
519519
const query = params.toString() ? `?${params}` : "";
520520
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/unpublish${query}`, {
521521
method: "POST",
522+
headers: { "Content-Type": "application/json" },
523+
body: JSON.stringify({ _rev: options?._rev }),
522524
});
523-
const data = await parseApiResponse<{ item: ContentItem }>(
525+
const data = await parseApiResponse<{ item: ContentItem; _rev?: string }>(
524526
response,
525527
"Failed to unpublish content",
526528
);
527-
return data.item;
529+
return { ...data.item, _rev: data._rev };
528530
}
529531

530532
/**
@@ -541,8 +543,11 @@ export async function discardDraft(
541543
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/discard-draft${query}`, {
542544
method: "POST",
543545
});
544-
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to discard draft");
545-
return data.item;
546+
const data = await parseApiResponse<{ item: ContentItem; _rev?: string }>(
547+
response,
548+
"Failed to discard draft",
549+
);
550+
return { ...data.item, _rev: data._rev };
546551
}
547552

548553
/**
@@ -633,9 +638,9 @@ export async function restoreRevision(revisionId: string): Promise<ContentItem>
633638
await throwResponseError(response, i18n._(msg`Failed to restore revision`));
634639
}
635640

636-
const data = await parseApiResponse<{ item: ContentItem }>(
641+
const data = await parseApiResponse<{ item: ContentItem; _rev?: string }>(
637642
response,
638643
i18n._(msg`Failed to restore revision`),
639644
);
640-
return data.item;
645+
return { ...data.item, _rev: data._rev };
641646
}

packages/admin/src/router.tsx

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,7 @@ function ContentEditPage() {
883883
revisionTokensRef.current.set(rawItem.id, rawItem._rev);
884884
}
885885
const editorSaveQueueRef = React.useRef<Promise<void>>(Promise.resolve());
886+
const unpublishRequestRef = React.useRef<Promise<void> | null>(null);
886887
const serializeEditorSave = React.useCallback(<T,>(operation: () => Promise<T>) => {
887888
const result = editorSaveQueueRef.current.then(operation);
888889
editorSaveQueueRef.current = result.then(
@@ -1189,8 +1190,13 @@ function ContentEditPage() {
11891190
});
11901191

11911192
const unpublishMutation = useMutation({
1192-
mutationFn: () => unpublishContent(collection, id, { locale: rawItem?.locale ?? activeLocale }),
1193-
onSuccess: () => {
1193+
mutationFn: (_rev?: string) =>
1194+
unpublishContent(collection, id, {
1195+
locale: rawItem?.locale ?? activeLocale,
1196+
_rev,
1197+
}),
1198+
onSuccess: (unpublishedItem) => {
1199+
revisionTokensRef.current.set(id, unpublishedItem._rev);
11941200
void queryClient.invalidateQueries({
11951201
queryKey: ["content", collection, id],
11961202
});
@@ -1209,8 +1215,9 @@ function ContentEditPage() {
12091215

12101216
const discardDraftMutation = useMutation({
12111217
mutationFn: () => discardDraft(collection, id, { locale: rawItem?.locale ?? activeLocale }),
1212-
onSuccess: () => {
1218+
onSuccess: (discardedItem) => {
12131219
setConflictedEntryId((conflicted) => (conflicted === id ? "" : conflicted));
1220+
revisionTokensRef.current.set(id, discardedItem._rev);
12141221
void queryClient.invalidateQueries({
12151222
queryKey: ["content", collection, id],
12161223
});
@@ -1465,8 +1472,42 @@ function ContentEditPage() {
14651472
],
14661473
);
14671474
const handleUnpublish = React.useCallback(
1468-
() => unpublishMutation.mutate(),
1469-
[unpublishMutation.mutate],
1475+
async (payload?: {
1476+
data: Record<string, unknown>;
1477+
slug?: string;
1478+
bylines?: BylineCreditInput[];
1479+
}) => {
1480+
if (unpublishRequestRef.current) return unpublishRequestRef.current;
1481+
1482+
const request = (async () => {
1483+
const savedItem = await serializeEditorSave(async () => {
1484+
if (!payload) return;
1485+
return updateMutation.mutateAsync({
1486+
targetId: id,
1487+
targetLocale: rawItem?.locale ?? activeLocale,
1488+
source: "editor",
1489+
changes: payload,
1490+
});
1491+
});
1492+
const currentToken = savedItem?._rev ?? revisionTokensRef.current.get(id);
1493+
await unpublishMutation.mutateAsync(currentToken);
1494+
})();
1495+
unpublishRequestRef.current = request;
1496+
void request
1497+
.catch(() => undefined)
1498+
.finally(() => {
1499+
if (unpublishRequestRef.current === request) unpublishRequestRef.current = null;
1500+
});
1501+
return request;
1502+
},
1503+
[
1504+
activeLocale,
1505+
id,
1506+
rawItem?.locale,
1507+
serializeEditorSave,
1508+
unpublishMutation.mutateAsync,
1509+
updateMutation.mutateAsync,
1510+
],
14701511
);
14711512
const handleDiscardDraft = React.useCallback(
14721513
() => discardDraftMutation.mutate(),
@@ -1545,6 +1586,14 @@ function ContentEditPage() {
15451586
updateBylineMutation.mutateAsync({ id: bylineId, ...input }),
15461587
[updateBylineMutation.mutateAsync],
15471588
);
1589+
const handleRevisionRestored = React.useCallback(
1590+
(restoredItem: ContentItem) => {
1591+
if (restoredItem._rev) {
1592+
revisionTokensRef.current.set(id, restoredItem._rev);
1593+
}
1594+
},
1595+
[id],
1596+
);
15481597

15491598
if (!manifest) {
15501599
return <LoadingScreen />;
@@ -1582,6 +1631,7 @@ function ContentEditPage() {
15821631
onPublish={handlePublish}
15831632
onUnpublish={handleUnpublish}
15841633
onDiscardDraft={handleDiscardDraft}
1634+
onRevisionRestored={handleRevisionRestored}
15851635
onSchedule={handleSchedule}
15861636
onUnschedule={handleUnschedule}
15871637
isScheduling={scheduleMutation.isPending}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
import { discardDraft, restoreRevision, unpublishContent } from "../../../src/lib/api/index.js";
4+
5+
const CONTENT_ITEM = {
6+
id: "post_1",
7+
type: "posts",
8+
slug: "post-one",
9+
status: "published",
10+
locale: "en",
11+
translationGroup: null,
12+
data: { title: "Sample" },
13+
authorId: null,
14+
primaryBylineId: null,
15+
createdAt: "2026-01-01T00:00:00Z",
16+
updatedAt: "2026-01-02T00:00:00Z",
17+
publishedAt: "2026-01-01T00:00:00Z",
18+
scheduledAt: null,
19+
liveRevisionId: "rev-live",
20+
draftRevisionId: "rev-draft",
21+
};
22+
23+
function jsonResponse(body: unknown) {
24+
return new Response(JSON.stringify({ data: body }), {
25+
status: 200,
26+
headers: { "Content-Type": "application/json" },
27+
});
28+
}
29+
30+
describe("Content token APIs", () => {
31+
let originalFetch: typeof fetch;
32+
33+
beforeEach(() => {
34+
originalFetch = globalThis.fetch;
35+
});
36+
37+
afterEach(() => {
38+
globalThis.fetch = originalFetch;
39+
});
40+
41+
it("unpublishContent returns the new _rev token from the response", async () => {
42+
globalThis.fetch = vi.fn(async () =>
43+
jsonResponse({ item: CONTENT_ITEM, _rev: "rev-unpublish-1" }),
44+
) as typeof fetch;
45+
46+
const result = await unpublishContent("posts", "post_1");
47+
expect(result._rev).toBe("rev-unpublish-1");
48+
});
49+
50+
it("unpublishContent forwards the current _rev token when provided", async () => {
51+
const requests: { url: string; body: Record<string, unknown> }[] = [];
52+
globalThis.fetch = vi.fn(async (input, init?) => {
53+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
54+
requests.push({
55+
url,
56+
body: typeof init?.body === "string" ? JSON.parse(init.body) : {},
57+
});
58+
return jsonResponse({ item: CONTENT_ITEM, _rev: "rev-unpublish-1" });
59+
}) as typeof fetch;
60+
61+
await unpublishContent("posts", "post_1", { _rev: "rev-initial" });
62+
expect(requests).toHaveLength(1);
63+
expect(requests[0]?.url).toBe("/_emdash/api/content/posts/post_1/unpublish");
64+
expect(requests[0]?.body).toEqual({ _rev: "rev-initial" });
65+
});
66+
67+
it("discardDraft returns the new _rev token from the response", async () => {
68+
globalThis.fetch = vi.fn(async () =>
69+
jsonResponse({
70+
item: { ...CONTENT_ITEM, status: "published", draftRevisionId: null },
71+
_rev: "rev-discard-1",
72+
}),
73+
) as typeof fetch;
74+
75+
const result = await discardDraft("posts", "post_1");
76+
expect(result._rev).toBe("rev-discard-1");
77+
});
78+
79+
it("restoreRevision returns the new _rev token from the response", async () => {
80+
globalThis.fetch = vi.fn(async () =>
81+
jsonResponse({
82+
item: { ...CONTENT_ITEM, data: { title: "Restored" } },
83+
_rev: "rev-restore-1",
84+
}),
85+
) as typeof fetch;
86+
87+
const result = await restoreRevision("revision-old");
88+
expect(result._rev).toBe("rev-restore-1");
89+
});
90+
});

0 commit comments

Comments
 (0)