diff --git a/.changeset/quiet-publish-flush.md b/.changeset/quiet-publish-flush.md new file mode 100644 index 0000000000..5ed01f1982 --- /dev/null +++ b/.changeset/quiet-publish-flush.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Fixes Publish saving and awaiting the editor's latest changes before making content live. Validation errors, failed saves, and revision conflicts now stop publishing instead of promoting stale draft data. diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 025085c33f..36e4eb2271 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -159,7 +159,11 @@ export interface ContentEditorProps { autosaveCompletionToken?: number; /** Entry-scoped token advanced after the server rejected an autosave payload. */ autosaveRejectionToken?: number; - onPublish?: () => void; + onPublish?: (payload: { + data: Record; + slug?: string; + bylines?: BylineCreditInput[]; + }) => void | Promise; onUnpublish?: () => void; /** Callback to discard draft changes (revert to published version) */ onDiscardDraft?: () => void; @@ -340,6 +344,8 @@ export function ContentEditor({ ); const pendingAutosaveStateRef = React.useRef(null); const [rejectedAutosaveState, setRejectedAutosaveState] = React.useState(null); + const [isPublishing, setIsPublishing] = React.useState(false); + const isPublishingRef = React.useRef(false); // Synchronously reset form state when the underlying item changes (e.g. a // translation switch where TanStack Router keeps ContentEditor mounted but @@ -384,11 +390,14 @@ export function ContentEditor({ React.useEffect(() => { if (item) { const nextBylines = resolveEditorBylines(item).explicitCredits; - setFormData(item.data); - setSlug(item.slug || ""); - setSlugTouched(!!item.slug); + if (!isPublishingRef.current) { + setFormData(item.data); + setSlug(item.slug || ""); + setSlugTouched(!!item.slug); + setInternalBylines(nextBylines); + setBylinesTouched(false); + } setStatus(item.status); - setInternalBylines(nextBylines); setLastSavedData( serializeEditorState({ data: item.data, @@ -398,7 +407,6 @@ export function ContentEditor({ ); pendingAutosaveStateRef.current = null; setRejectedAutosaveState(null); - setBylinesTouched(false); } }, [item?.updatedAt, itemDataString, itemBylinesString, item?.slug, item?.status]); @@ -484,10 +492,28 @@ export function ContentEditor({ }, [fields], ); + const createSavePayload = React.useCallback(() => { + const payload: { + data: Record; + slug?: string; + bylines?: BylineCreditInput[]; + } = { + data: formDataRef.current, + slug: slugRef.current || undefined, + }; + if (isNew || bylinesTouched) payload.bylines = activeBylines; + return payload; + }, [activeBylines, bylinesTouched, isNew]); + const cancelPendingAutosave = React.useCallback(() => { + if (autosaveTimeoutRef.current) { + clearTimeout(autosaveTimeoutRef.current); + autosaveTimeoutRef.current = null; + } + }, []); React.useEffect(() => { // Don't autosave for new items (no ID yet) or if autosave isn't configured - if (isNew || !onAutosave || !item?.id || hasUnsupportedPortableTextMarks) { + if (isNew || !onAutosave || !item?.id || hasUnsupportedPortableTextMarks || isPublishing) { return; } @@ -508,15 +534,7 @@ export function ContentEditor({ // Schedule autosave autosaveTimeoutRef.current = setTimeout(() => { if (hasInvalidUrls(formDataRef.current)) return; - const payload: { - data: Record; - slug?: string; - bylines?: BylineCreditInput[]; - } = { - data: formDataRef.current, - slug: slugRef.current || undefined, - }; - if (bylinesTouched) payload.bylines = activeBylines; + const payload = createSavePayload(); pendingAutosaveStateRef.current = serializeEditorState({ data: payload.data, slug: payload.slug || "", @@ -540,31 +558,70 @@ export function ContentEditor({ isAutosaving, activeBylines, bylinesTouched, + createSavePayload, hasInvalidUrls, hasUnsupportedPortableTextMarks, + isPublishing, rejectedAutosaveState, ]); // Cancel pending autosave on manual save const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (hasInvalidUrls(formData) || hasUnsupportedPortableTextMarks) return; - // Cancel pending autosave - if (autosaveTimeoutRef.current) { - clearTimeout(autosaveTimeoutRef.current); - autosaveTimeoutRef.current = null; - } - const payload: { - data: Record; - slug?: string; - bylines?: BylineCreditInput[]; - } = { - data: formData, - slug: slug || undefined, - }; - if (isNew || bylinesTouched) payload.bylines = activeBylines; - onSave?.(payload); + if ( + isContentSaveBlocked || + isPublishingRef.current || + hasInvalidUrls(formData) || + hasUnsupportedPortableTextMarks + ) + return; + cancelPendingAutosave(); + onSave?.(createSavePayload()); }; + const handlePublish = React.useCallback(() => { + if ( + isPublishingRef.current || + !onPublish || + hasInvalidUrls(formDataRef.current) || + hasUnsupportedPortableTextMarks + ) + return; + cancelPendingAutosave(); + const payload = createSavePayload(); + const savedState = serializeEditorState({ + data: payload.data, + slug: payload.slug || "", + bylines: activeBylines, + }); + isPublishingRef.current = true; + setIsPublishing(true); + const result = onPublish(payload); + if (!result) { + isPublishingRef.current = false; + setIsPublishing(false); + return; + } + void result.then( + () => { + setLastSavedData(savedState); + isPublishingRef.current = false; + setIsPublishing(false); + return undefined; + }, + () => { + isPublishingRef.current = false; + setIsPublishing(false); + return undefined; + }, + ); + }, [ + activeBylines, + cancelPendingAutosave, + createSavePayload, + hasInvalidUrls, + hasUnsupportedPortableTextMarks, + onPublish, + ]); // Preview URL state const [isLoadingPreview, setIsLoadingPreview] = React.useState(false); @@ -751,7 +808,7 @@ export function ContentEditor({ isNew={isNew} isLive={isLive} hasPendingChanges={hasPendingChanges} - onPublish={onPublish} + onPublish={handlePublish} onUnpublish={onUnpublish} /> @@ -810,7 +867,7 @@ export function ContentEditor({ collectionLabel={collectionLabel} isLive={isLive} hasPendingChanges={hasPendingChanges} - onPublish={onPublish} + onPublish={handlePublish} onUnpublish={onUnpublish} size="sm" /> @@ -896,7 +953,7 @@ export function ContentEditor({ supportsPreview={supportsPreview} isLoadingPreview={isLoadingPreview} onPreview={handlePreview} - onPublish={onPublish} + onPublish={handlePublish} onUnpublish={onUnpublish} announceSaveStatus={!isDistractionFree} /> diff --git a/packages/admin/src/lib/api/content.ts b/packages/admin/src/lib/api/content.ts index a79bc99203..13eccb6a43 100644 --- a/packages/admin/src/lib/api/content.ts +++ b/packages/admin/src/lib/api/content.ts @@ -487,16 +487,21 @@ export async function getPreviewUrl( export async function publishContent( collection: string, id: string, - options?: { locale?: string }, + options?: { locale?: string; _rev?: string }, ): Promise { const params = new URLSearchParams(); if (options?.locale) params.set("locale", options.locale); const query = params.toString() ? `?${params}` : ""; const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/publish${query}`, { method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ _rev: options?._rev }), }); - const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to publish content"); - return data.item; + const data = await parseApiResponse<{ item: ContentItem; _rev?: string }>( + response, + "Failed to publish content", + ); + return { ...data.item, _rev: data._rev }; } /** diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index bfb0b3b1c0..3409af0f22 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -161,7 +161,7 @@ interface ContentUpdateChanges { bylines?: BylineCreditInput[]; skipRevision?: boolean; seo?: ContentSeoInput; - /** Echo of the last-read token so the server rejects stale saves (#2121). */ + /** Optimistic-concurrency token from the latest response. */ _rev?: string; } @@ -861,6 +861,25 @@ function ContentEditPage() { queryFn: () => fetchContent(collection, id, { locale: activeLocale }), enabled: !i18n || !!activeLocale, }); + const revisionTokensRef = React.useRef(new Map()); + const activeRevisionEntryRef = React.useRef(""); + if (activeRevisionEntryRef.current !== id) { + activeRevisionEntryRef.current = id; + revisionTokensRef.current.delete(id); + } + if (rawItem && !revisionTokensRef.current.has(rawItem.id)) { + revisionTokensRef.current.set(rawItem.id, rawItem._rev); + } + const editorSaveQueueRef = React.useRef>(Promise.resolve()); + const serializeEditorSave = React.useCallback((operation: () => Promise) => { + const result = editorSaveQueueRef.current.then(operation); + editorSaveQueueRef.current = result.then( + () => undefined, + () => undefined, + ); + return result; + }, []); + const publishRequestRef = React.useRef | null>(null); React.useEffect(() => { if (typeof searchParams.field !== "string" || isLoading) return; @@ -1026,8 +1045,16 @@ function ContentEditPage() { ); const updateMutation = useMutation({ - mutationFn: ({ targetId, targetLocale, changes }: ContentUpdateMutationInput) => - updateContent(collection, targetId, changes, { locale: targetLocale }), + mutationFn: async ({ targetId, targetLocale, changes }: ContentUpdateMutationInput) => { + const savedItem = await updateContent( + collection, + targetId, + { ...changes, _rev: revisionTokensRef.current.get(targetId) }, + { locale: targetLocale }, + ); + revisionTokensRef.current.set(targetId, savedItem._rev); + return savedItem; + }, onMutate: (variables) => { if (variables.source === "editor") { updateEditorSavePendingCount(variables.targetId, 1); @@ -1044,8 +1071,16 @@ function ContentEditPage() { }, }); const publishedAtMutation = useMutation({ - mutationFn: (publishedAt: string) => - updateContent(collection, id, { publishedAt }, { locale: rawItem?.locale ?? activeLocale }), + mutationFn: async (publishedAt: string) => { + const savedItem = await updateContent( + collection, + id, + { publishedAt }, + { locale: rawItem?.locale ?? activeLocale }, + ); + revisionTokensRef.current.set(id, savedItem._rev); + return savedItem; + }, onSuccess: () => { handleContentUpdateSuccess(id); }, @@ -1054,13 +1089,16 @@ function ContentEditPage() { // Autosave mutation - skips revision creation const autosaveMutation = useMutation({ - mutationFn: ({ targetId, targetLocale, changes }: AutosaveMutationInput) => - updateContent( + mutationFn: async ({ targetId, targetLocale, changes }: AutosaveMutationInput) => { + const savedItem = await updateContent( collection, targetId, - { ...changes, skipRevision: true }, + { ...changes, skipRevision: true, _rev: revisionTokensRef.current.get(targetId) }, { locale: targetLocale }, - ), + ); + revisionTokensRef.current.set(targetId, savedItem._rev); + return savedItem; + }, onSuccess: (savedItem, variables) => { recordAutosaveCompletion(variables.targetId); patchAutosaveQueries(queryClient, { @@ -1086,11 +1124,17 @@ function ContentEditPage() { }); const publishMutation = useMutation({ - mutationFn: () => publishContent(collection, id, { locale: rawItem?.locale ?? activeLocale }), - onSuccess: () => { - void queryClient.invalidateQueries({ - queryKey: ["content", collection, id], - }); + mutationFn: (revision: string | undefined) => + publishContent(collection, id, { + locale: rawItem?.locale ?? activeLocale, + _rev: revision, + }), + onSuccess: (publishedItem) => { + revisionTokensRef.current.set(id, publishedItem._rev); + queryClient.setQueriesData( + { queryKey: ["content", collection, id] }, + publishedItem, + ); void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] }); toastManager.add({ title: t`Published`, description: t`Content is now live` }); }, @@ -1241,31 +1285,31 @@ function ContentEditPage() { // ContentSettingsPanel, so fresh arrows on every mutation-state flip // (twice per autosave cycle) would defeat the memo. mutate/mutateAsync // are referentially stable. - // Echo the optimistic-concurrency token from the last read so the server - // rejects a save based on a stale entry (#2121) instead of silently - // overwriting a newer draft revision. `rawItem` is the unhydrated content - // GET response, which carries `_rev`. const handleSave = React.useCallback( (payload: { data: Record; slug?: string; bylines?: BylineCreditInput[] }) => { - updateMutation.mutate({ - targetId: id, - targetLocale: rawItem?.locale ?? activeLocale, - source: "editor", - changes: { ...payload, _rev: rawItem?._rev }, - }); + void serializeEditorSave(() => + updateMutation.mutateAsync({ + targetId: id, + targetLocale: rawItem?.locale ?? activeLocale, + source: "editor", + changes: payload, + }), + ).catch(() => undefined); }, - [activeLocale, id, rawItem?.locale, rawItem?._rev, updateMutation.mutate], + [activeLocale, id, rawItem?.locale, serializeEditorSave, updateMutation.mutateAsync], ); const handleAutosave = React.useCallback( (payload: { data: Record; slug?: string; bylines?: BylineCreditInput[] }) => { - autosaveMutation.mutate({ - targetId: id, - targetLocale: rawItem?.locale ?? activeLocale, - changes: { ...payload, _rev: rawItem?._rev }, - }); + void serializeEditorSave(() => + autosaveMutation.mutateAsync({ + targetId: id, + targetLocale: rawItem?.locale ?? activeLocale, + changes: payload, + }), + ).catch(() => undefined); }, - [activeLocale, autosaveMutation.mutate, id, rawItem?.locale, rawItem?._rev], + [activeLocale, autosaveMutation.mutateAsync, id, rawItem?.locale, serializeEditorSave], ); const handleAuthorChange = React.useCallback( (authorId: string | null) => { @@ -1273,10 +1317,10 @@ function ContentEditPage() { targetId: id, targetLocale: rawItem?.locale ?? activeLocale, source: "auxiliary", - changes: { authorId, _rev: rawItem?._rev }, + changes: { authorId }, }); }, - [activeLocale, id, rawItem?.locale, rawItem?._rev, updateMutation.mutate], + [activeLocale, id, rawItem?.locale, updateMutation.mutate], ); const handlePublishedAtChange = React.useCallback( (publishedAt: string) => { @@ -1291,13 +1335,44 @@ function ContentEditPage() { targetId: id, targetLocale: rawItem?.locale ?? activeLocale, source: "auxiliary", - changes: { seo, _rev: rawItem?._rev }, + changes: { seo }, }); }, - [activeLocale, id, rawItem?.locale, rawItem?._rev, updateMutation.mutate], + [activeLocale, id, rawItem?.locale, updateMutation.mutate], ); - const handlePublish = React.useCallback(() => publishMutation.mutate(), [publishMutation.mutate]); + const handlePublish = React.useCallback( + (payload: { data: Record; slug?: string; bylines?: BylineCreditInput[] }) => { + if (publishRequestRef.current) return publishRequestRef.current; + + const request = (async () => { + const savedItem = await serializeEditorSave(() => + updateMutation.mutateAsync({ + targetId: id, + targetLocale: rawItem?.locale ?? activeLocale, + source: "editor", + changes: payload, + }), + ); + await publishMutation.mutateAsync(savedItem._rev); + })(); + publishRequestRef.current = request; + void request + .catch(() => undefined) + .finally(() => { + if (publishRequestRef.current === request) publishRequestRef.current = null; + }); + return request; + }, + [ + activeLocale, + id, + publishMutation.mutateAsync, + rawItem?.locale, + serializeEditorSave, + updateMutation.mutateAsync, + ], + ); const handleUnpublish = React.useCallback( () => unpublishMutation.mutate(), [unpublishMutation.mutate], @@ -1349,7 +1424,9 @@ function ContentEditPage() { collectionLabel={collectionConfig.labelSingular || collectionConfig.label} item={item} fields={collectionConfig.fields} - isSaving={updateMutation.isPending || publishedAtMutation.isPending} + isSaving={ + updateMutation.isPending || publishedAtMutation.isPending || publishMutation.isPending + } isSaveFeedbackActive={(editorSavePendingCounts.get(id) ?? 0) > 0} onSave={handleSave} onAutosave={handleAutosave} diff --git a/packages/admin/tests/publish-autosave-race.test.tsx b/packages/admin/tests/publish-autosave-race.test.tsx new file mode 100644 index 0000000000..46998f5077 --- /dev/null +++ b/packages/admin/tests/publish-autosave-race.test.tsx @@ -0,0 +1,398 @@ +import { Toasty } from "@cloudflare/kumo"; +import { i18n } from "@lingui/core"; +import { I18nProvider } from "@lingui/react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { RouterProvider } from "@tanstack/react-router"; +import * as React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { userEvent } from "vitest/browser"; + +import { ThemeProvider } from "../src/components/ThemeProvider"; +import type { AdminManifest, ContentItem } from "../src/lib/api"; +import { createAdminRouter } from "../src/router"; +import { render } from "./utils/render.tsx"; +import { createTestQueryClient } from "./utils/test-helpers.tsx"; + +const MANIFEST: AdminManifest = { + version: "1.0.0", + hash: "publish-race", + authMode: "passkey", + collections: { + posts: { + label: "Posts", + labelSingular: "Post", + supports: ["drafts", "revisions"], + hasSeo: false, + fields: { + title: { kind: "string", label: "Title" }, + website: { kind: "url", label: "Website" }, + }, + }, + }, + plugins: {}, + taxonomies: [], + i18n: undefined, +}; + +type RevisionedContentItem = ContentItem & { _rev: string }; + +function makeItem(overrides: Partial = {}): RevisionedContentItem { + return { + id: "post_1", + type: "posts", + slug: "post-one", + status: "published", + locale: "en", + translationGroup: null, + data: { title: "Draft title", website: "" }, + authorId: null, + primaryBylineId: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-02T00:00:00Z", + publishedAt: "2026-01-01T00:00:00Z", + scheduledAt: null, + liveRevisionId: "revision-live", + draftRevisionId: "revision-draft", + _rev: "rev-initial", + ...overrides, + }; +} + +interface RecordedRequest { + method: string; + url: string; + body: Record | undefined; +} + +interface MockServerOptions { + onPut?: (request: RecordedRequest, index: number) => Promise | Response; + onPublish?: (request: RecordedRequest, index: number) => Promise | Response; +} + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function errorResponse(code: string, message: string, status: number) { + return jsonResponse({ error: { code, message } }, status); +} + +function contentResponse(item: RevisionedContentItem) { + const { _rev, ...contentItem } = item; + return jsonResponse({ data: { item: contentItem, _rev } }); +} + +function createMockServer(options: MockServerOptions = {}) { + const originalFetch = globalThis.fetch; + const requests: RecordedRequest[] = []; + let putCount = 0; + let publishCount = 0; + + globalThis.fetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = (init?.method ?? "GET").toUpperCase(); + const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined; + const request = { method, url, body }; + requests.push(request); + + if (method === "GET" && url === "/_emdash/api/manifest") { + return jsonResponse({ data: MANIFEST }); + } + if (method === "GET" && url === "/_emdash/api/auth/me") { + return jsonResponse({ data: { id: "user_1", role: 40 } }); + } + if (method === "GET" && url.startsWith("/_emdash/api/bylines")) { + return jsonResponse({ data: { items: [] } }); + } + if (method === "GET" && url.startsWith("/_emdash/api/users")) { + return jsonResponse({ data: { items: [] } }); + } + if (method === "GET" && url.startsWith("/_emdash/api/content/posts/post_1")) { + return contentResponse(makeItem()); + } + if (method === "GET" && url === "/_emdash/api/revisions/revision-draft") { + return jsonResponse({ + data: { + item: { + id: "revision-draft", + collection: "posts", + entryId: "post_1", + data: { title: "Draft title", website: "" }, + authorId: null, + createdAt: "2026-01-02T00:00:00Z", + }, + }, + }); + } + if (method === "PUT" && url.startsWith("/_emdash/api/content/posts/post_1")) { + const index = putCount++; + if (options.onPut) return options.onPut(request, index); + return contentResponse(makeItem({ _rev: `rev-save-${index + 1}` })); + } + if (method === "POST" && url.startsWith("/_emdash/api/content/posts/post_1/publish")) { + const index = publishCount++; + if (options.onPublish) return options.onPublish(request, index); + const savedData = requests.findLast( + (candidate) => candidate.method === "PUT" && candidate.body?.data, + )?.body?.data as Record | undefined; + return contentResponse( + makeItem({ + _rev: `rev-publish-${index + 1}`, + data: savedData ?? { title: "Draft title", website: "" }, + liveRevisionId: "revision-draft", + }), + ); + } + + throw new Error(`Unhandled request: ${method} ${url}`); + }) as typeof fetch; + + return { + requests, + restore() { + globalThis.fetch = originalFetch; + }, + }; +} + +function buildRouter() { + const queryClient = createTestQueryClient(); + const router = createAdminRouter(queryClient); + if (!i18n.locale) i18n.loadAndActivate({ locale: "en", messages: {} }); + + function TestApp() { + return ( + + + + + + + + + + ); + } + + return { router, TestApp }; +} + +async function renderEditPage() { + const { router, TestApp } = buildRouter(); + await router.navigate({ + to: "/content/$collection/$id", + params: { collection: "posts", id: "post_1" }, + }); + const screen = await render(); + await expect.element(screen.getByRole("button", { name: "Publish", exact: true })).toBeVisible(); + return screen; +} + +function contentMutations(requests: RecordedRequest[]) { + return requests.filter( + (request) => + request.method === "PUT" || (request.method === "POST" && request.url.includes("/publish")), + ); +} + +function deferredResponse() { + let resolve!: (response: Response) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +describe("ContentEditPage publish and autosave ordering", () => { + let server: ReturnType | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + server?.restore(); + server = undefined; + vi.useRealTimers(); + }); + + it("flushes the current payload before publish and cancels the pending debounce", async () => { + server = createMockServer(); + const screen = await renderEditPage(); + + await screen.getByRole("textbox", { name: "Title" }).fill("Latest title"); + await screen.getByRole("button", { name: "Publish", exact: true }).click(); + await vi.advanceTimersByTimeAsync(0); + + const mutations = contentMutations(server.requests); + expect(mutations.map(({ method }) => method)).toEqual(["PUT", "POST"]); + expect(mutations[0]?.body).toMatchObject({ + data: { title: "Latest title" }, + _rev: "rev-initial", + }); + expect(mutations[1]?.body).toEqual({ _rev: "rev-save-1" }); + + await vi.advanceTimersByTimeAsync(2500); + expect(contentMutations(server.requests)).toHaveLength(2); + }); + + it("queues the publish flush behind an in-flight autosave", async () => { + const autosave = deferredResponse(); + server = createMockServer({ + onPut: (_request, index) => { + if (index === 0) return autosave.promise; + return contentResponse(makeItem({ _rev: "rev-flush" })); + }, + }); + const screen = await renderEditPage(); + + const title = screen.getByRole("textbox", { name: "Title" }); + await title.fill("Autosave title"); + await vi.advanceTimersByTimeAsync(2000); + expect(contentMutations(server.requests)).toHaveLength(1); + + await title.fill("Publish title"); + await screen.getByRole("button", { name: "Publish", exact: true }).click(); + await vi.advanceTimersByTimeAsync(0); + expect(contentMutations(server.requests)).toHaveLength(1); + + autosave.resolve(contentResponse(makeItem({ _rev: "rev-autosave" }))); + await vi.waitFor(() => { + expect(contentMutations(server!.requests).map(({ method }) => method)).toEqual([ + "PUT", + "PUT", + "POST", + ]); + }); + + const mutations = contentMutations(server.requests); + expect(mutations[1]?.body).toMatchObject({ + data: { title: "Publish title" }, + _rev: "rev-autosave", + }); + expect(mutations[2]?.body).toEqual({ _rev: "rev-flush" }); + }); + + it.each([ + ["network failure", () => Promise.reject(new TypeError("Network unavailable"))], + ["server failure", () => errorResponse("CONTENT_UPDATE_ERROR", "Save failed", 500)], + ["revision conflict", () => errorResponse("CONFLICT", "Content changed", 409)], + ])("does not publish after a %s while flushing", async (_name, failure) => { + server = createMockServer({ onPut: failure }); + const screen = await renderEditPage(); + + await screen.getByRole("textbox", { name: "Title" }).fill("Latest title"); + await screen.getByRole("button", { name: "Publish", exact: true }).click(); + await vi.advanceTimersByTimeAsync(0); + + expect(contentMutations(server.requests).map(({ method }) => method)).toEqual(["PUT"]); + }); + + it("does not save or publish an invalid editor payload", async () => { + server = createMockServer(); + const screen = await renderEditPage(); + + await screen.getByRole("textbox", { name: "Website" }).fill("not a URL"); + await screen.getByRole("button", { name: "Publish", exact: true }).click(); + await vi.advanceTimersByTimeAsync(2500); + + expect(contentMutations(server.requests)).toEqual([]); + }); + + it("coalesces repeated publish clicks and advances the revision token after save and publish", async () => { + server = createMockServer(); + const screen = await renderEditPage(); + const title = screen.getByRole("textbox", { name: "Title" }); + const publish = screen.getByRole("button", { name: "Publish", exact: true }); + + await title.fill("First publish"); + publish.element().click(); + publish.element().click(); + await vi.advanceTimersByTimeAsync(0); + expect(contentMutations(server.requests).map(({ method }) => method)).toEqual(["PUT", "POST"]); + + await title.fill("After publish"); + await vi.advanceTimersByTimeAsync(2000); + const mutations = contentMutations(server.requests); + expect(mutations.map(({ method }) => method)).toEqual(["PUT", "POST", "PUT"]); + expect(mutations[2]?.body).toMatchObject({ _rev: "rev-publish-1" }); + }); + + it("keeps edits made after a coalesced publish click dirty", async () => { + const save = deferredResponse(); + server = createMockServer({ + onPut: (request, index) => { + if (index === 0) return save.promise; + return contentResponse( + makeItem({ + _rev: "rev-after-publish", + data: request.body?.data as Record, + }), + ); + }, + onPublish: () => + contentResponse( + makeItem({ + _rev: "rev-published", + data: { title: "First publish", website: "" }, + liveRevisionId: "revision-draft", + }), + ), + }); + const screen = await renderEditPage(); + const title = screen.getByRole("textbox", { name: "Title" }); + const publish = screen.getByRole("button", { name: "Publish", exact: true }); + + await title.fill("First publish"); + publish.element().click(); + await vi.waitFor(() => expect(contentMutations(server!.requests)).toHaveLength(1)); + await title.fill("Second edit"); + publish.element().click(); + + save.resolve( + contentResponse( + makeItem({ _rev: "rev-saved", data: { title: "First publish", website: "" } }), + ), + ); + await vi.waitFor(() => + expect(contentMutations(server!.requests).map(({ method }) => method)).toEqual([ + "PUT", + "POST", + ]), + ); + await vi.advanceTimersByTimeAsync(2000); + await vi.waitFor(() => expect(contentMutations(server!.requests)).toHaveLength(3)); + + const mutations = contentMutations(server.requests); + expect(mutations[2]?.body).toMatchObject({ + data: { title: "Second edit" }, + _rev: "rev-published", + }); + }); + + it("ignores Enter-key form submission while publish is in flight", async () => { + const publishResponse = deferredResponse(); + server = createMockServer({ onPublish: () => publishResponse.promise }); + const screen = await renderEditPage(); + const title = screen.getByRole("textbox", { name: "Title" }); + + await title.fill("Publish title"); + await screen.getByRole("button", { name: "Publish", exact: true }).click(); + await vi.waitFor(() => + expect(contentMutations(server!.requests).map(({ method }) => method)).toEqual([ + "PUT", + "POST", + ]), + ); + + title.element().focus(); + await userEvent.keyboard("{Enter}"); + await vi.advanceTimersByTimeAsync(0); + expect(contentMutations(server.requests).map(({ method }) => method)).toEqual(["PUT", "POST"]); + + publishResponse.resolve(contentResponse(makeItem({ _rev: "rev-published" }))); + }); +});