diff --git a/.changeset/stale-draft-restore.md b/.changeset/stale-draft-restore.md
new file mode 100644
index 000000000..432828782
--- /dev/null
+++ b/.changeset/stale-draft-restore.md
@@ -0,0 +1,5 @@
+---
+'@keystatic/core': patch
+---
+
+A draft is now only restored automatically if the entry hasn't changed since the draft was saved. Otherwise the saved entry is shown along with a toast offering to restore the draft, so saving can no longer silently overwrite newer content with an old draft.
diff --git a/packages/keystatic/src/app/ItemPage.tsx b/packages/keystatic/src/app/ItemPage.tsx
index f19cc2de7..66c4a3552 100644
--- a/packages/keystatic/src/app/ItemPage.tsx
+++ b/packages/keystatic/src/app/ItemPage.tsx
@@ -80,6 +80,8 @@ import {
getSlugFromState,
isGitHubConfig,
useShowRestoredDraftMessage,
+ getAutomaticallyRestorableDraft,
+ useStaleDraft,
} from './utils';
import { DataState, LOADING, useData, suspendOnData } from './useData';
import { useYJsValue } from './useYJsValue';
@@ -389,7 +391,9 @@ function LocalItemPage(
const { collectionConfig, schema } = useCollection(collection);
const [{ state, localTreeKey: localTreeKeyInState }, setState] = useState({
- state: draft?.state ?? initialState,
+ state:
+ getAutomaticallyRestorableDraft(draft, localTreeKey)?.state ??
+ initialState,
localTreeKey,
});
@@ -420,6 +424,18 @@ function LocalItemPage(
slugField: collectionConfig.slugField,
});
+ const keepStaleDraft = useStaleDraft(
+ draft,
+ localTreeKey,
+ hasChanged,
+ draftState => {
+ setState(state => ({
+ localTreeKey: state.localTreeKey,
+ state: draftState,
+ }));
+ }
+ );
+
const slug = getSlugFromState(collectionConfig, state);
const formatInfo = getCollectionFormat(config, collection);
const futureBasePath = getCollectionItemPath(config, collection, slug);
@@ -453,7 +469,7 @@ function LocalItemPage(
version: 1,
};
setDraft(key, data);
- } else {
+ } else if (!keepStaleDraft) {
delDraft(key);
}
}, [
@@ -461,6 +477,7 @@ function LocalItemPage(
collectionConfig,
config,
futureBasePath,
+ keepStaleDraft,
localTreeKey,
props.itemSlug,
slug,
diff --git a/packages/keystatic/src/app/SingletonPage.tsx b/packages/keystatic/src/app/SingletonPage.tsx
index e64eef51e..8cb6c909a 100644
--- a/packages/keystatic/src/app/SingletonPage.tsx
+++ b/packages/keystatic/src/app/SingletonPage.tsx
@@ -29,6 +29,8 @@ import {
isCloudConfig,
isGitHubConfig,
useShowRestoredDraftMessage,
+ getAutomaticallyRestorableDraft,
+ useStaleDraft,
} from './utils';
import { CreateBranchDuringUpdateDialog } from './ItemPage';
@@ -359,7 +361,7 @@ function LocalSingletonPage(
() => ({
localTreeKey: localTreeKey,
state:
- draft?.state ??
+ getAutomaticallyRestorableDraft(draft, localTreeKey)?.state ??
(initialState === null ? getInitialPropsValue(schema) : initialState),
})
);
@@ -379,6 +381,18 @@ function LocalSingletonPage(
useHasChanged({ initialState, state, schema, slugField: undefined }) ||
isCreating;
+ const keepStaleDraft = useStaleDraft(
+ draft,
+ localTreeKey,
+ hasChanged,
+ draftState => {
+ setState(state => ({
+ localTreeKey: state.localTreeKey,
+ state: draftState,
+ }));
+ }
+ );
+
useEffect(() => {
const key = ['singleton', singleton] as const;
if (hasChanged) {
@@ -397,11 +411,12 @@ function LocalSingletonPage(
version: 1,
};
setDraft(key, data);
- } else {
+ } else if (!keepStaleDraft) {
delDraft(key);
}
}, [
config,
+ keepStaleDraft,
localTreeKey,
state,
hasChanged,
diff --git a/packages/keystatic/src/app/drafts.test.tsx b/packages/keystatic/src/app/drafts.test.tsx
new file mode 100644
index 000000000..76d7cc1c0
--- /dev/null
+++ b/packages/keystatic/src/app/drafts.test.tsx
@@ -0,0 +1,84 @@
+/** @vitest-environment jsdom */
+import { act, renderHook } from '@testing-library/react';
+import { StrictMode } from 'react';
+import { beforeEach, expect, test, vi } from 'vitest';
+
+const staleToasts: { savedAt: Date; onRestore: () => void }[] = [];
+
+vi.mock('./persistence', () => ({
+ showDraftRestoredToast: vi.fn(),
+ showStaleDraftToast: (savedAt: Date, onRestore: () => void) => {
+ staleToasts.push({ savedAt, onRestore });
+ },
+}));
+
+const { getAutomaticallyRestorableDraft, useStaleDraft } = await import(
+ './utils'
+);
+
+beforeEach(() => {
+ staleToasts.length = 0;
+});
+
+const draft = {
+ state: { title: 'draft' },
+ savedAt: new Date('2026-01-01'),
+ treeKey: 'tree-a',
+};
+
+test('a draft saved against the current tree is restored automatically', () => {
+ expect(getAutomaticallyRestorableDraft(draft, 'tree-a')).toBe(draft);
+});
+
+test('a draft saved against an older tree is not restored automatically', () => {
+ expect(getAutomaticallyRestorableDraft(draft, 'tree-b')).toBeUndefined();
+});
+
+test('no draft means nothing to restore or keep', () => {
+ const { result } = renderHook(() =>
+ useStaleDraft(undefined, 'tree-a', false, () => {})
+ );
+ expect(result.current).toBe(false);
+ expect(staleToasts).toHaveLength(0);
+});
+
+test('a current draft is not treated as stale', () => {
+ const { result } = renderHook(() =>
+ useStaleDraft(draft, 'tree-a', false, () => {})
+ );
+ expect(result.current).toBe(false);
+ expect(staleToasts).toHaveLength(0);
+});
+
+test('a stale draft is kept and offered for restoring', () => {
+ const restore = vi.fn();
+ const { result } = renderHook(() =>
+ useStaleDraft(draft, 'tree-b', false, restore)
+ );
+ expect(result.current).toBe(true);
+ expect(staleToasts).toHaveLength(1);
+ expect(staleToasts[0].savedAt).toBe(draft.savedAt);
+
+ act(() => staleToasts[0].onRestore());
+ expect(restore).toHaveBeenCalledWith(draft.state);
+ expect(result.current).toBe(false);
+});
+
+test('a stale draft stops being kept once the user makes changes', () => {
+ const { result, rerender } = renderHook(
+ ({ hasChanged }) => useStaleDraft(draft, 'tree-b', hasChanged, () => {}),
+ { initialProps: { hasChanged: false } }
+ );
+ expect(result.current).toBe(true);
+ rerender({ hasChanged: true });
+ expect(result.current).toBe(false);
+ rerender({ hasChanged: false });
+ expect(result.current).toBe(false);
+});
+
+test('a stale draft toast is only shown once in strict mode', () => {
+ renderHook(() => useStaleDraft(draft, 'tree-b', false, () => {}), {
+ wrapper: StrictMode,
+ });
+ expect(staleToasts).toHaveLength(1);
+});
diff --git a/packages/keystatic/src/app/persistence.tsx b/packages/keystatic/src/app/persistence.tsx
index 4ff335ed9..3b5c65962 100644
--- a/packages/keystatic/src/app/persistence.tsx
+++ b/packages/keystatic/src/app/persistence.tsx
@@ -62,6 +62,20 @@ export function showDraftRestoredToast(
);
}
+export function showStaleDraftToast(savedAt: Date, onRestore: () => void) {
+ toastQueue.info(
+
+ A draft from was not restored because this
+ entry has changed since the draft was saved.
+ ,
+ {
+ actionLabel: 'Restore draft',
+ onAction: onRestore,
+ shouldCloseOnAction: true,
+ }
+ );
+}
+
let store: UseStore;
function getStore() {
diff --git a/packages/keystatic/src/app/utils.ts b/packages/keystatic/src/app/utils.ts
index fd2a11e15..ae1e02392 100644
--- a/packages/keystatic/src/app/utils.ts
+++ b/packages/keystatic/src/app/utils.ts
@@ -15,8 +15,8 @@ import { collectDirectoriesUsedInSchema, getTreeKey } from './tree-key';
import { getTreeNodeAtPath, TreeNode } from './trees';
import pkgJson from '../../package.json';
import { object } from '../form/fields/object';
-import { useEffect } from 'react';
-import { showDraftRestoredToast } from './persistence';
+import { useEffect, useRef, useState } from 'react';
+import { showDraftRestoredToast, showStaleDraftToast } from './persistence';
import { useEffectEvent } from 'react-aria/private/utils/useEffectEvent';
export * from './path-utils';
@@ -243,3 +243,55 @@ export function useShowRestoredDraftMessage(
}
}, [draft, show]);
}
+
+type Draft = {
+ state: Record;
+ savedAt: Date;
+ treeKey?: string | undefined;
+};
+
+// a draft is only restored automatically if the entry hasn't changed since
+// the draft was saved, otherwise saving it would overwrite those changes
+export function getAutomaticallyRestorableDraft(
+ draft: D | undefined,
+ localTreeKey: string | undefined
+): D | undefined {
+ return draft !== undefined && draft.treeKey === localTreeKey
+ ? draft
+ : undefined;
+}
+
+// offers to restore a draft that wasn't restored automatically. returns
+// whether the draft should be kept even though the state hasn't changed,
+// which is until the user restores it or makes changes of their own
+export function useStaleDraft(
+ draft: Draft | undefined,
+ localTreeKey: string | undefined,
+ hasChanged: boolean,
+ restore: (state: Record) => void
+) {
+ const [staleDraft, setStaleDraft] = useState(() =>
+ draft !== undefined &&
+ getAutomaticallyRestorableDraft(draft, localTreeKey) === undefined
+ ? draft
+ : undefined
+ );
+ if (staleDraft !== undefined && hasChanged) {
+ setStaleDraft(undefined);
+ }
+ // effects run twice in strict mode so this stops the toast showing twice
+ const shownFor = useRef(undefined);
+ const show = useEffectEvent(() => {
+ if (staleDraft && shownFor.current !== staleDraft) {
+ shownFor.current = staleDraft;
+ showStaleDraftToast(staleDraft.savedAt, () => {
+ setStaleDraft(undefined);
+ restore(staleDraft.state);
+ });
+ }
+ });
+ useEffect(() => {
+ show();
+ }, [show]);
+ return staleDraft !== undefined;
+}