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 .changeset/stale-draft-restore.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 19 additions & 2 deletions packages/keystatic/src/app/ItemPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ import {
getSlugFromState,
isGitHubConfig,
useShowRestoredDraftMessage,
getAutomaticallyRestorableDraft,
useStaleDraft,
} from './utils';
import { DataState, LOADING, useData, suspendOnData } from './useData';
import { useYJsValue } from './useYJsValue';
Expand Down Expand Up @@ -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,
});

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -453,14 +469,15 @@ function LocalItemPage(
version: 1,
};
setDraft(key, data);
} else {
} else if (!keepStaleDraft) {
delDraft(key);
}
}, [
collection,
collectionConfig,
config,
futureBasePath,
keepStaleDraft,
localTreeKey,
props.itemSlug,
slug,
Expand Down
19 changes: 17 additions & 2 deletions packages/keystatic/src/app/SingletonPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
isCloudConfig,
isGitHubConfig,
useShowRestoredDraftMessage,
getAutomaticallyRestorableDraft,
useStaleDraft,
} from './utils';

import { CreateBranchDuringUpdateDialog } from './ItemPage';
Expand Down Expand Up @@ -359,7 +361,7 @@ function LocalSingletonPage(
() => ({
localTreeKey: localTreeKey,
state:
draft?.state ??
getAutomaticallyRestorableDraft(draft, localTreeKey)?.state ??
(initialState === null ? getInitialPropsValue(schema) : initialState),
})
);
Expand All @@ -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) {
Expand All @@ -397,11 +411,12 @@ function LocalSingletonPage(
version: 1,
};
setDraft(key, data);
} else {
} else if (!keepStaleDraft) {
delDraft(key);
}
}, [
config,
keepStaleDraft,
localTreeKey,
state,
hasChanged,
Expand Down
84 changes: 84 additions & 0 deletions packages/keystatic/src/app/drafts.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
14 changes: 14 additions & 0 deletions packages/keystatic/src/app/persistence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ export function showDraftRestoredToast(
);
}

export function showStaleDraftToast(savedAt: Date, onRestore: () => void) {
toastQueue.info(
<Text>
A draft from <RelativeTime date={savedAt} /> was not restored because this
entry has changed since the draft was saved.
</Text>,
{
actionLabel: 'Restore draft',
onAction: onRestore,
shouldCloseOnAction: true,
}
);
}

let store: UseStore;

function getStore() {
Expand Down
56 changes: 54 additions & 2 deletions packages/keystatic/src/app/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -243,3 +243,55 @@ export function useShowRestoredDraftMessage(
}
}, [draft, show]);
}

type Draft = {
state: Record<string, unknown>;
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<D extends Draft>(
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<string, unknown>) => 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<Draft | undefined>(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;
}