Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export interface ContentEditorProps {
data: Record<string, unknown>;
slug?: string;
bylines?: BylineCreditInput[];
shouldApplyResponse?: () => boolean;
}) => void;
/** Whether autosave is in progress */
isAutosaving?: boolean;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down
68 changes: 60 additions & 8 deletions packages/admin/src/components/PortableTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<ReturnType<typeof portableTextToProsemirror> | 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.
Expand All @@ -1541,7 +1573,7 @@ export function PortableTextEditor({

const editor = useEditor({
extensions,
content: initialContent as Parameters<typeof useEditor>[0]["content"],
content: initialContentRef.current as Parameters<typeof useEditor>[0]["content"],
editable,
immediatelyRender: true,
editorProps,
Expand All @@ -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<typeof prosemirrorToPortableText>[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) {
Expand Down
45 changes: 45 additions & 0 deletions packages/admin/src/lib/autosave-cache.ts
Original file line number Diff line number Diff line change
@@ -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<ContentItem, "data" | "slug">): Record<string, unknown> {
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<Revision>(draftRevisionQueryKey);

if (!existingDraftRevision) {
return;
}

queryClient.setQueryData<Revision>(draftRevisionQueryKey, {
...existingDraftRevision,
data: buildDraftRevisionData(savedItem),
});
}
19 changes: 14 additions & 5 deletions packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -618,11 +619,19 @@ function ContentEditPage() {
data?: Record<string, unknown>;
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({
Expand Down
27 changes: 27 additions & 0 deletions packages/admin/tests/components/ContentEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
41 changes: 41 additions & 0 deletions packages/admin/tests/editor/PortableTextEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<PortableTextEditor value={[textBlock("Initial text")]} onChange={onChange} />,
);
let pm = await waitForEditor();
expect(pm.textContent).toBe("Initial text");

onChange.mockClear();

await screen.rerender(
<PortableTextEditor
value={[textBlock("Updated first line"), textBlock("Updated second line")]}
onChange={onChange}
/>,
);

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(<PortableTextEditor value={[textBlock("Stable text")]} />);
await waitForEditor();

const undoBtn = screen.getByRole("button", { name: "Undo" });
await expect.element(undoBtn).toBeDisabled();

await screen.rerender(<PortableTextEditor value={[textBlock("Stable text")]} />);

await vi.waitFor(async () => {
await expect.element(undoBtn).toBeDisabled();
});
});
});

// =============================================================================
Expand Down
81 changes: 81 additions & 0 deletions packages/admin/tests/lib/autosave-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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> = {}): 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();
});
});
Loading
Loading