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/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/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/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(); + }); + }); }); // ============================================================================= 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(); + }); +}); 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"); + }); +});