From a6407e4d117b3b7b5eda4ad69ccfc4745d7e4c0b Mon Sep 17 00:00:00 2001 From: Oliver George Date: Sun, 11 Oct 2026 10:01:23 +1100 Subject: [PATCH] fix(app): don't auto-restore a draft when the entry has changed since Fixes #1594. Drafts were restored over the loaded entry unconditionally, so a draft from an earlier session could hide newer content on disk (or on GitHub) and pressing Save would overwrite it. The only signal was a transient "Restored draft" toast. Drafts already store the tree key they were made against. A draft is now only restored automatically when that matches the current tree key. Otherwise the saved entry is shown, with a toast offering "Restore draft". The stale draft is kept until it's restored or the user makes their own changes. --- .changeset/stale-draft-restore.md | 5 ++ packages/keystatic/src/app/ItemPage.tsx | 21 ++++- packages/keystatic/src/app/SingletonPage.tsx | 19 ++++- packages/keystatic/src/app/drafts.test.tsx | 84 ++++++++++++++++++++ packages/keystatic/src/app/persistence.tsx | 14 ++++ packages/keystatic/src/app/utils.ts | 56 ++++++++++++- 6 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 .changeset/stale-draft-restore.md create mode 100644 packages/keystatic/src/app/drafts.test.tsx 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; +}