From 78479eb8e7359dcfa7c6eeff17a4e48ec07d3bf0 Mon Sep 17 00:00:00 2001 From: Christopher Orr Date: Mon, 6 Apr 2026 00:11:24 +0200 Subject: [PATCH 1/3] Fix stale responses after draft autosaves Revision-backed autosaves were updating the draft revision but returning the base content row, so the API response could immediately disagree with the saved draft state. Reload the current draft revision after a successful save and merge its data and draft slug into the returned item. Add a regression test covering skipRevision updates on a revision-backed collection. Co-authored-by: Codex --- packages/core/src/emdash-runtime.ts | 34 +++++++ .../runtime-content-update-revisions.test.ts | 89 +++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 packages/core/tests/unit/runtime-content-update-revisions.test.ts diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 810b0bbae..996e7fa34 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -235,6 +235,32 @@ function contentItemToRecord(item: ContentItemInternal): Record return { ...item }; } +/** + * Merge draft revision content into a content item response. + * + * Revision-backed collections keep metadata on the content row while draft + * field data (and draft slug overrides) live in the revisions table. + */ +function mergeDraftRevisionIntoItem( + item: ContentItemInternal, + revisionData: Record, +): ContentItemInternal { + const draftData: Record = {}; + for (const [key, value] of Object.entries(revisionData)) { + if (!key.startsWith("_")) { + draftData[key] = value; + } + } + + const draftSlug = typeof revisionData._slug === "string" ? revisionData._slug : item.slug; + + return { + ...item, + slug: draftSlug, + data: { ...item.data, ...draftData }, + }; +} + // Module-level caches (persist across requests within worker) const dbCache = new Map>(); let dbInitPromise: Promise> | null = null; @@ -1524,6 +1550,14 @@ export class EmDashRuntime { bylines: bodyWithoutRev.bylines, }); + if (result.success && result.data && usesDraftRevisions && result.data.item.draftRevisionId) { + const revisionRepo = new RevisionRepository(this.db); + const draftRevision = await revisionRepo.findById(result.data.item.draftRevisionId); + if (draftRevision?.data) { + result.data.item = mergeDraftRevisionIntoItem(result.data.item, draftRevision.data); + } + } + // Run afterSave hooks (fire-and-forget) if (result.success && result.data) { this.runAfterSaveHooks(contentItemToRecord(result.data.item), collection, false); diff --git a/packages/core/tests/unit/runtime-content-update-revisions.test.ts b/packages/core/tests/unit/runtime-content-update-revisions.test.ts new file mode 100644 index 000000000..2c9fba5f4 --- /dev/null +++ b/packages/core/tests/unit/runtime-content-update-revisions.test.ts @@ -0,0 +1,89 @@ +import type { Kysely } from "kysely"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +import { handleContentCreate } from "../../src/api/index.js"; +import type { EmDashConfig } from "../../src/astro/integration/runtime.js"; +import { RevisionRepository } from "../../src/database/repositories/revision.js"; +import type { Database } from "../../src/database/types.js"; +import { EmDashRuntime, type RuntimeDependencies } from "../../src/emdash-runtime.js"; +import { runWithContext } from "../../src/request-context.js"; +import { SchemaRegistry } from "../../src/schema/registry.js"; +import { setupTestDatabase, teardownTestDatabase } from "../utils/test-db.js"; + +describe("EmDashRuntime.handleContentUpdate with revisions", () => { + let db: Kysely; + let runtime: EmDashRuntime; + + beforeEach(async () => { + db = await setupTestDatabase(); + + const registry = new SchemaRegistry(db); + await registry.createCollection({ + slug: "post", + label: "Posts", + labelSingular: "Post", + supports: ["drafts", "revisions"], + }); + await registry.createField("post", { + slug: "title", + label: "Title", + type: "string", + }); + await registry.createField("post", { + slug: "content", + label: "Content", + type: "portableText", + }); + + const deps: RuntimeDependencies = { + config: {} as EmDashConfig, + plugins: [], + createDialect: () => { + throw new Error("createDialect should not be used in this test"); + }, + createStorage: null, + sandboxEnabled: false, + mediaProviderEntries: [], + sandboxedPluginEntries: [], + createSandboxRunner: null, + }; + + runtime = await runWithContext({ editMode: false, db }, () => EmDashRuntime.create(deps)); + }); + + afterEach(async () => { + await runtime.stopCron(); + await teardownTestDatabase(db); + }); + + it("returns the updated draft data for autosave on revision-backed collections", async () => { + const created = await handleContentCreate(db, "post", { + data: { title: "Original title" }, + }); + expect(created.success).toBe(true); + + const id = created.data!.item.id; + + const firstSave = await runtime.handleContentUpdate("post", id, { + data: { title: "Draft one" }, + slug: "draft-one", + }); + expect(firstSave.success).toBe(true); + expect(firstSave.data?.item.draftRevisionId).toBeTruthy(); + + const autosaved = await runtime.handleContentUpdate("post", id, { + data: { title: "Draft two" }, + slug: "draft-two", + skipRevision: true, + }); + + expect(autosaved.success).toBe(true); + expect(autosaved.data?.item.data.title).toBe("Draft two"); + expect(autosaved.data?.item.slug).toBe("draft-two"); + + const revisionRepo = new RevisionRepository(db); + const draftRevision = await revisionRepo.findById(autosaved.data!.item.draftRevisionId!); + expect(draftRevision?.data.title).toBe("Draft two"); + expect(draftRevision?.data._slug).toBe("draft-two"); + }); +}); From c34c43f3963c77687baee47add5575d153786a41 Mon Sep 17 00:00:00 2001 From: Christopher Orr Date: Mon, 6 Apr 2026 00:11:25 +0200 Subject: [PATCH 2/3] Apply safe autosave responses to editor queries Update the admin autosave path so a PUT response is only applied back into the edit-page queries when the local editor state still matches the snapshot that was sent. This keeps autosave from triggering an extra GET for the same item, but avoids overwriting newer local edits with a stale response that arrives after the user keeps typing. Add focused regressions for the query-cache update helper and the ContentEditor snapshot guard. Co-authored-by: Codex --- .../admin/src/components/ContentEditor.tsx | 5 ++ packages/admin/src/lib/autosave-cache.ts | 45 +++++++++++ packages/admin/src/router.tsx | 19 +++-- .../tests/components/ContentEditor.test.tsx | 27 +++++++ .../admin/tests/lib/autosave-cache.test.ts | 81 +++++++++++++++++++ 5 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 packages/admin/src/lib/autosave-cache.ts create mode 100644 packages/admin/tests/lib/autosave-cache.test.ts diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index d55d82e70..c4bc3c770 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -103,6 +103,7 @@ export interface ContentEditorProps { data: Record; slug?: string; bylines?: BylineCreditInput[]; + shouldApplyResponse?: () => boolean; }) => void; /** Whether autosave is in progress */ isAutosaving?: boolean; @@ -300,6 +301,8 @@ export function ContentEditor({ }), [formData, slug, activeBylines], ); + const currentDataRef = React.useRef(currentData); + currentDataRef.current = currentData; const isDirty = isNew || currentData !== lastSavedData; // Autosave with debounce @@ -327,11 +330,13 @@ export function ContentEditor({ } // Schedule autosave + const autosaveSnapshot = currentData; autosaveTimeoutRef.current = setTimeout(() => { onAutosave({ data: formDataRef.current, slug: slugRef.current || undefined, bylines: activeBylines, + shouldApplyResponse: () => currentDataRef.current === autosaveSnapshot, }); }, AUTOSAVE_DELAY); diff --git a/packages/admin/src/lib/autosave-cache.ts b/packages/admin/src/lib/autosave-cache.ts new file mode 100644 index 000000000..5d4a294ac --- /dev/null +++ b/packages/admin/src/lib/autosave-cache.ts @@ -0,0 +1,45 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import type { ContentItem, Revision } from "./api"; + +/* + * The content query stores the merged edit item as { slug, data }, but the + * draft revision query stores fields directly in revision.data with the draft + * slug under _slug. Rebuild that revision payload shape before updating it. + */ +function buildDraftRevisionData(item: Pick): Record { + return { + ...item.data, + _slug: item.slug, + }; +} + +/* + * Apply a saved item to the edit page's two related queries: + * - ["content", collection, id] keeps the item's metadata and merged field data + * - ["revision", draftRevisionId] holds the draft working copy when one is loaded + */ +export function applyAutosaveResultToQueryCache( + queryClient: QueryClient, + collection: string, + id: string, + savedItem: ContentItem, +): void { + queryClient.setQueryData(["content", collection, id], savedItem); + + if (!savedItem.draftRevisionId) { + return; + } + + const draftRevisionQueryKey = ["revision", savedItem.draftRevisionId] as const; + const existingDraftRevision = queryClient.getQueryData(draftRevisionQueryKey); + + if (!existingDraftRevision) { + return; + } + + queryClient.setQueryData(draftRevisionQueryKey, { + ...existingDraftRevision, + data: buildDraftRevisionData(savedItem), + }); +} diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index c8ffb58a6..d734a298f 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -107,6 +107,7 @@ import { bulkCommentAction, type CommentStatus, } from "./lib/api/comments"; +import { applyAutosaveResultToQueryCache } from "./lib/autosave-cache"; import { usePluginPage } from "./lib/plugin-context"; import { sanitizeRedirectUrl } from "./lib/url"; import { BylinesPage } from "./routes/bylines"; @@ -241,7 +242,7 @@ function ContentListPage() { queryFn: ({ pageParam }) => fetchContentList(collection, { locale: activeLocale, - cursor: pageParam as string | undefined, + cursor: pageParam, limit: 100, }), initialPageParam: undefined as string | undefined, @@ -618,11 +619,19 @@ function ContentEditPage() { data?: Record; slug?: string; bylines?: BylineCreditInput[]; - }) => updateContent(collection, id, { ...data, skipRevision: true }), - onSuccess: () => { + shouldApplyResponse?: () => boolean; + }) => { + const { shouldApplyResponse: _shouldApplyResponse, ...request } = data; + return updateContent(collection, id, { ...request, skipRevision: true }); + }, + onSuccess: (savedItem, variables) => { setLastAutosaveAt(new Date()); - // Silently update the cache without full invalidation - void queryClient.invalidateQueries({ queryKey: ["content", collection, id] }); + // Ignore stale autosave responses once local editor state has advanced. + // A newer local snapshot should stay authoritative until the next save + if (!variables.shouldApplyResponse?.()) { + return; + } + applyAutosaveResultToQueryCache(queryClient, collection, id, savedItem); }, onError: (err) => { toastManager.add({ diff --git a/packages/admin/tests/components/ContentEditor.test.tsx b/packages/admin/tests/components/ContentEditor.test.tsx index 57359ca25..7a985f684 100644 --- a/packages/admin/tests/components/ContentEditor.test.tsx +++ b/packages/admin/tests/components/ContentEditor.test.tsx @@ -178,6 +178,33 @@ describe("ContentEditor", () => { const savedBtn = screen.getByRole("button", { name: "Saved" }); await expect.element(savedBtn).toBeDisabled(); }); + + it("ignores an autosave response once local state has advanced", async () => { + vi.useFakeTimers(); + try { + const onAutosave = vi.fn(); + const item = makeItem(); + const screen = await renderEditor({ isNew: false, item, onAutosave }); + + const titleInput = screen.getByLabelText("Title"); + await titleInput.fill("First autosave snapshot"); + + // Wait for autosave to be triggered + await vi.advanceTimersByTimeAsync(2000); + + expect(onAutosave).toHaveBeenCalledTimes(1); + const payload = onAutosave.mock.calls[0]![0] as { + shouldApplyResponse?: () => boolean; + }; + expect(payload.shouldApplyResponse?.()).toBe(true); + + await titleInput.fill("Newer local edit"); + + expect(payload.shouldApplyResponse?.()).toBe(false); + } finally { + vi.useRealTimers(); + } + }); }); describe("delete", () => { diff --git a/packages/admin/tests/lib/autosave-cache.test.ts b/packages/admin/tests/lib/autosave-cache.test.ts new file mode 100644 index 000000000..852fffdae --- /dev/null +++ b/packages/admin/tests/lib/autosave-cache.test.ts @@ -0,0 +1,81 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; + +import type { ContentItem, Revision } from "../../src/lib/api"; +import { applyAutosaveResultToQueryCache } from "../../src/lib/autosave-cache"; + +function makeContentItem(overrides: Partial = {}): ContentItem { + return { + id: "post-1", + type: "posts", + slug: "draft-slug", + status: "published", + locale: "en", + translationGroup: "post-1", + data: { + title: "Updated title", + excerpt: "Updated excerpt", + }, + authorId: null, + primaryBylineId: null, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-05T00:00:00.000Z", + publishedAt: "2026-04-01T00:00:00.000Z", + scheduledAt: null, + liveRevisionId: "rev-live", + draftRevisionId: "rev-draft", + ...overrides, + }; +} + +function makeRevision(overrides: Partial = {}): Revision { + return { + id: "rev-draft", + collection: "posts", + entryId: "post-1", + data: { + title: "Old title", + excerpt: "Old excerpt", + _slug: "old-slug", + }, + authorId: null, + createdAt: "2026-04-04T00:00:00.000Z", + ...overrides, + }; +} + +describe("applyAutosaveResultToQueryCache", () => { + it("updates the cached content item and active draft revision", () => { + const queryClient = new QueryClient(); + const savedItem = makeContentItem(); + + queryClient.setQueryData( + ["content", "posts", "post-1"], + makeContentItem({ data: { title: "Old title" } }), + ); + queryClient.setQueryData(["revision", "rev-draft"], makeRevision()); + + applyAutosaveResultToQueryCache(queryClient, "posts", "post-1", savedItem); + + expect(queryClient.getQueryData(["content", "posts", "post-1"])).toEqual(savedItem); + expect(queryClient.getQueryData(["revision", "rev-draft"])).toEqual( + expect.objectContaining({ + data: { + title: "Updated title", + excerpt: "Updated excerpt", + _slug: "draft-slug", + }, + }), + ); + }); + + it("leaves an uncached draft revision untouched", () => { + const queryClient = new QueryClient(); + const savedItem = makeContentItem(); + + applyAutosaveResultToQueryCache(queryClient, "posts", "post-1", savedItem); + + expect(queryClient.getQueryData(["content", "posts", "post-1"])).toEqual(savedItem); + expect(queryClient.getQueryData(["revision", "rev-draft"])).toBeUndefined(); + }); +}); From c3020e06d4a373775d56625cbd5caf07dd07e503 Mon Sep 17 00:00:00 2001 From: Christopher Orr Date: Mon, 6 Apr 2026 00:11:25 +0200 Subject: [PATCH 3/3] Sync editor content after external value changes Reconcile PortableTextEditor with post-mount value updates from parent state, while avoiding unnecessary resets for equivalent content. Add browser regressions covering external value replacement without onChange noise and preserving clean history for equivalent updates. Co-authored-by: Codex --- .../src/components/PortableTextEditor.tsx | 68 ++++++++++++++++--- .../tests/editor/PortableTextEditor.test.tsx | 41 +++++++++++ 2 files changed, 101 insertions(+), 8 deletions(-) diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index d1e0f55b6..491811e85 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -42,7 +42,7 @@ import { type Icon, } from "@phosphor-icons/react"; import { X } from "@phosphor-icons/react"; -import { Extension, type Range } from "@tiptap/core"; +import { Extension, type Content, type Range } from "@tiptap/core"; import CharacterCount from "@tiptap/extension-character-count"; import Focus from "@tiptap/extension-focus"; import Placeholder from "@tiptap/extension-placeholder"; @@ -473,6 +473,26 @@ function portableTextToProsemirror(blocks: PortableTextBlock[]): { }; } +function stripPortableTextKeys(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(stripPortableTextKeys); + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => key !== "_key") + .map(([key, entryValue]) => [key, stripPortableTextKeys(entryValue)]), + ); + } + + return value; +} + +function serializePortableTextForComparison(blocks: PortableTextBlock[]): string { + return JSON.stringify(stripPortableTextKeys(blocks)); +} + function convertPTBlock(block: PortableTextBlock): unknown { switch (block._type) { case "block": { @@ -1465,12 +1485,6 @@ export function PortableTextEditor({ return [...titleMatches, ...otherMatches]; }; - // Convert initial value to ProseMirror format - const initialContent = React.useMemo( - () => portableTextToProsemirror(value || []), - [], // Only compute once on mount - ); - // Memoize the entire extensions array so TipTap never diffs/replaces // plugins on re-render. The loop was: extension array changes → useEditor // calls setOptions → old Suggestion plugin destroyed → onExit fires @@ -1526,6 +1540,24 @@ export function PortableTextEditor({ [], // Created once — all mutable state accessed via refs ); + // The initial ProseMirror document passed to the editor when it's created. + // TipTap treats this as boot input, so keep it stable for the lifetime of the editor instance + const initialContentRef = React.useRef | null>(null); + if (!initialContentRef.current) { + initialContentRef.current = portableTextToProsemirror(value || []); + } + + // The latest ProseMirror document derived from the `value` prop. + // If changed after mount, this is what we push into the editor via setContent() + const externalContent = React.useMemo(() => portableTextToProsemirror(value || []), [value]); + + // A stable comparison key for the current `value` prop, based on Portable Text. + // This lets the reconciliation effect ignore editor-specific ProseMirror differences + const externalPortableTextSignature = React.useMemo( + () => serializePortableTextForComparison(value || []), + [value], + ); + // Stable editorProps reference — a new object every render would cause // compareOptions to call setOptions → updateState → plugin teardown → // Suggestion onExit → setSlashMenuState → re-render → infinite loop. @@ -1541,7 +1573,7 @@ export function PortableTextEditor({ const editor = useEditor({ extensions, - content: initialContent as Parameters[0]["content"], + content: initialContentRef.current as Parameters[0]["content"], editable, immediatelyRender: true, editorProps, @@ -1557,6 +1589,26 @@ export function PortableTextEditor({ }, }); + // TipTap only reads the initial boot document from useEditor({ content }), + // so later external value changes must be reconciled into the live editor + React.useEffect(() => { + if (!editor) { + return; + } + + // Do nothing if, after normalization, the updated value prop's content matches the editor's current content + const currentPortableTextSignature = serializePortableTextForComparison( + prosemirrorToPortableText( + editor.getJSON() as Parameters[0], + ), + ); + if (currentPortableTextSignature === externalPortableTextSignature) { + return; + } + + editor.commands.setContent(externalContent as Content, { emitUpdate: false }); + }, [editor, externalContent, externalPortableTextSignature]); + // Notify when editor is ready React.useEffect(() => { if (editor && onEditorReady) { diff --git a/packages/admin/tests/editor/PortableTextEditor.test.tsx b/packages/admin/tests/editor/PortableTextEditor.test.tsx index a3b91209a..78d38d167 100644 --- a/packages/admin/tests/editor/PortableTextEditor.test.tsx +++ b/packages/admin/tests/editor/PortableTextEditor.test.tsx @@ -464,6 +464,47 @@ describe("Editor component behaviour", () => { { timeout: 2000 }, ); }); + + it("updates editor content when value changes without firing onChange", async () => { + const onChange = vi.fn(); + const screen = await render( + , + ); + let pm = await waitForEditor(); + expect(pm.textContent).toBe("Initial text"); + + onChange.mockClear(); + + await screen.rerender( + , + ); + + pm = await waitForEditor(); + await vi.waitFor(() => { + const paragraphs = pm.querySelectorAll("p"); + expect(paragraphs.length).toBe(2); + expect(paragraphs[0]!.textContent).toBe("Updated first line"); + expect(paragraphs[1]!.textContent).toBe("Updated second line"); + }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("does not reset editor history for equivalent value updates", async () => { + const screen = await render(); + await waitForEditor(); + + const undoBtn = screen.getByRole("button", { name: "Undo" }); + await expect.element(undoBtn).toBeDisabled(); + + await screen.rerender(); + + await vi.waitFor(async () => { + await expect.element(undoBtn).toBeDisabled(); + }); + }); }); // =============================================================================